From 3c26783a0dbee1d2dbbe3c179b46a17877574c38 Mon Sep 17 00:00:00 2001 From: rinekri Date: Thu, 16 Jul 2026 12:18:09 +0100 Subject: [PATCH] Rework plugin for lazy configuration and dependency wiring --- .codegraph/.gitignore | 5 + .../workflows/gradle-wrapper-validation.yml | 2 +- .github/workflows/pre-merge.yaml | 9 +- .github/workflows/publish-plugin.yaml | 8 +- .gitignore | 6 +- CHANGELOG.md | 69 ++ MIGRATION.md | 187 +++ README.md | 183 ++- build-conventions/build.gradle | 1 - .../src/main/groovy/kotlin-convention.gradle | 2 - build.gradle.kts | 1 - example-project/build.gradle.kts | 19 +- gradle.properties | 2 + gradle/libs.versions.toml | 30 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 283 +++-- gradlew.bat | 63 +- plugin-build/build.gradle.kts | 2 - .../plugin-foundation/build.gradle.kts | 27 +- .../plugin/foundation/AggregateTasksWiring.kt | 86 ++ .../plugin/foundation/AndroidLintWiring.kt | 32 + .../foundation/AppQualityFoundationPlugin.kt | 578 +-------- .../plugin/foundation/ConfigFileResolution.kt | 144 +++ .../quality/plugin/foundation/DetektWiring.kt | 279 +++++ .../quality/plugin/foundation/KtlintWiring.kt | 186 +++ .../foundation/config/AndroidLintConfig.kt | 18 + .../plugin/foundation/config/DetektConfig.kt | 77 +- .../config/ExternalDependencyConfig.kt | 66 ++ .../plugin/foundation/config/KtlintConfig.kt | 49 +- .../foundation/config/PlatformDetektConfig.kt | 28 +- .../foundation/config/SourcePatternsConfig.kt | 40 + .../AppQualityFoundationExtension.kt | 52 + .../plugin/foundation/messages/Messages.kt | 83 +- .../task/GenerateDefaultConfigFileTask.kt | 29 + .../task/GenerateDefaultRulesJarTask.kt | 32 + .../foundation/task/GitHooksSetupTask.kt | 43 + .../plugin/foundation/utils/Extensions.kt | 182 ++- .../validate/AgpVersionsValidator.kt | 16 + .../detekt/default.kotlin-config.yml | 3 - .../detekt/rules/kode-android-rules-1.4.0.jar | Bin 0 -> 33166 bytes plugin-build/settings.gradle.kts | 3 +- plugin-test/build.gradle.kts | 2 - plugin-test/foundation/build.gradle.kts | 9 +- .../plugin/foundation/AggregateTasksTest.kt | 226 ++++ .../foundation/AgpVersionsValidatorTest.kt | 232 ++++ .../foundation/ConfigurationCacheTest.kt | 159 +++ .../plugin/foundation/DependencyWiringTest.kt | 535 +++++++++ .../foundation/DetektConfigurationTest.kt | 834 +++++++++++++ .../quality/plugin/foundation/ExampleTest.kt | 37 - .../quality/plugin/foundation/GitHooksTest.kt | 109 ++ .../JetbrainsComposeConfigurationTest.kt | 87 ++ .../foundation/KotlinDslConsumerTest.kt | 62 + .../KotlinMultiplatformConfigurationTest.kt | 70 ++ .../foundation/KtlintConfigurationTest.kt | 353 ++++++ .../plugin/foundation/RealProjectShapeTest.kt | 212 ++++ .../quality/plugin/foundation/TestFixtures.kt | 189 +++ plugin-test/settings.gradle.kts | 1 + .../plugin/test/utils/AlwaysInfoLogger.kt | 5 +- .../test/utils/AndroidProjectBuilders.kt | 1054 ----------------- .../plugin/test/utils/ApkExtensions.kt | 59 - .../plugin/test/utils/GradleRunners.kt | 122 ++ .../plugin/test/utils/GrgitExtensions.kt | 41 +- .../test/utils/QualityProjectBuilders.kt | 600 ++++++++++ 64 files changed, 5844 insertions(+), 2085 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 CHANGELOG.md create mode 100644 MIGRATION.md create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksWiring.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AndroidLintWiring.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigFileResolution.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektWiring.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintWiring.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/AndroidLintConfig.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/ExternalDependencyConfig.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/SourcePatternsConfig.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultConfigFileTask.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultRulesJarTask.kt create mode 100644 plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GitHooksSetupTask.kt create mode 100644 plugin-build/plugin-foundation/src/main/resources/detekt/rules/kode-android-rules-1.4.0.jar create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AgpVersionsValidatorTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigurationCacheTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DependencyWiringTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektConfigurationTest.kt delete mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ExampleTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/GitHooksTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/JetbrainsComposeConfigurationTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinDslConsumerTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinMultiplatformConfigurationTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintConfigurationTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/RealProjectShapeTest.kt create mode 100644 plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/TestFixtures.kt delete mode 100644 plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AndroidProjectBuilders.kt delete mode 100644 plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/ApkExtensions.kt create mode 100644 plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GradleRunners.kt create mode 100644 plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/QualityProjectBuilders.kt diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 71d1d16..3f22518 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout latest code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v3 diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index e37b0d4..3037213 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -18,20 +18,21 @@ jobs: if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} steps: - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Cache Gradle Caches - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.gradle/caches/ key: cache-gradle-cache - name: Cache Gradle Wrapper - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.gradle/wrapper/ key: cache-gradle-wrapper - name: Setup java - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: 'temurin' java-version: '17' - name: Run Gradle tasks id: gradle_run diff --git a/.github/workflows/publish-plugin.yaml b/.github/workflows/publish-plugin.yaml index b64f383..40d0a89 100644 --- a/.github/workflows/publish-plugin.yaml +++ b/.github/workflows/publish-plugin.yaml @@ -20,19 +20,19 @@ jobs: if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} steps: - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Cache Gradle Caches - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.gradle/caches/ key: cache-gradle-cache - name: Cache Gradle Wrapper - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.gradle/wrapper/ key: cache-gradle-wrapper - name: Setup java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'corretto' java-version: '17' diff --git a/.gitignore b/.gitignore index 5327829..0a68345 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ !/.idea/encodings.xml .DS_Store /build -*.salive \ No newline at end of file +*.salive +CLAUDE.md +AGENTS.md +.claude +.omc \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bba9239 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-08-17 + +### Changed + +- Reworked the plugin for lazy configuration and dependency wiring (breaking). Removed + `rulesPluginJar`/`rulesPluginJars`, unified every external dependency into a single + `from(...)` slot API (`ktlint.cli`, `detekt..rules`), replaced scattered + source-pattern properties with `sources { include/exclude/useDefaults }` blocks. See + [MIGRATION.md](MIGRATION.md). +- Added a bundled default for `detekt.android.rules` — no longer requires an implicit + `libs/detekt-rules-1.4.0.jar` pickup. + +### Added + +- `detekt.baseline` — optional baseline file to suppress pre-existing findings (e.g. for incremental + adoption on legacy modules). +- `detekt.xmlReportEnabled` / `detekt.sarifReportEnabled` — opt-in emitters for XML and SARIF report + formats per detekt task. +- `androidLint.enabled` — opt-in wiring to integrate Android Gradle Plugin lint checks into + `pipelineCheck` and `prePushCheck` aggregate tasks (off by default: lint is slow). +- `generateDefaultDetektAndroidRulesJar` task — materializes bundled KODE Android detekt rules jar + under `/build/app-quality/detekt/rules/`. +- Full test coverage across all DSL/config surfaces, including a real Kotlin-DSL (`.gradle.kts`) + consumer test, Kotlin Multiplatform module coverage, an `org.jetbrains.compose` (Compose + Multiplatform) functional test, and a zero-config "real production shape" test mirroring the + three current adopters. +- Documentation completion: accurate README examples, full backfilled `CHANGELOG.md`. + +## [1.0.8] - 2026-04-09 + +- Updated ktlint to a newer version, plus additional dependencies. +- Added `README.md` with project info. + +## [1.0.7] - 2026-03-26 + +- Added logic to register the `pipelineCheck` task. + +## [1.0.6] - 2026-03-25 + +- Added logic to provide libraries from the version catalog. + +## [1.0.5] - 2026-03-25 + +- Reverted provider usage for detekt tasks; removed non-cacheable logic. + +## [1.0.3] - 2026-03-25 + +- Fixed configuration-cache issues and logger usage; reworked detekt configuration logic. +- Moved logger usage to task execution via build services. + +## [1.0.2] - 2026-03-24 + +- Fixed ktlint check to use the correct logger. +- Fixed detekt ignored build types handling. +- Added sources configuration. + +## [1.0.1] - 2026-03-24 + +- Initial tagged release. +- Added a JVM target fallback when no Kotlin tasks are present. +- Removed a duplicate core library dependency (reused from build-publish-core). +- Fixed ktlint and config handling. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..1a81727 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,187 @@ +# Migration Guide + +Upgrade notes per release. Sections list breaking changes first, then behavior changes and +new opt-in capabilities. + +## 2.0.0 (dependency wiring rework) — migrating from 1.0.8 + +### Breaking: `detekt..rulesPluginJar` removed + +The single-file `rulesPluginJar: RegularFileProperty` no longer exists. Rule jars are added +through the platform's unified `rules` slot: + +```kotlin +// before (1.0.8): +appQualityFoundation { + detekt.kotlin.rulesPluginJar.set(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar")) +} + +// after: +appQualityFoundation { + detekt.kotlin.rules { + from(files(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar"))) + } +} +``` + +### Breaking: implicit `/libs/detekt-rules-1.4.0.jar` default removed — superseded below + +Previously the plugin silently picked up `/libs/detekt-rules-1.4.0.jar` when it +existed. That implicit, path-based pickup is gone. **However**, see "New: bundled default for +`detekt.android.rules`" below — for the common case (bundled `default.android-config.yml`, +which is the only config that activates `kode:` today) the plugin now supplies an equivalent +default again, just as an explicit, inspectable dependency slot instead of a silent file +convention. Projects with a custom detekt config that activates `kode:` (not the bundled one) +still need the explicit `rules { from(files(...)) }` shown above, or detekt fails config +validation with an unknown `kode` rule set. + +### New: bundled default for `detekt.android.rules` + +`detekt.android.rules` now has a real default: the plugin bundles its own `kode:` rules jar +(not published to any Maven repo — verified against Maven Central; the only published +`ru.kode` detekt artifact is `detekt-rules-compose`, a different ruleset) and wires it in +automatically while `useDefaults` is `true` (the default). Zero-config projects using the +plugin's bundled `default.android-config.yml` need **no action** — this restores the +1.0.7/1.0.8 zero-config experience for the KODE `RouteWiringMethodNaming` rule, just via an +inspectable slot instead of an implicit file convention. Only projects that explicitly set +`detekt.android.rules { useDefaults.set(false) }` need to supply their own jar/coordinate. + +### New: `ktlint.cli`/`detekt.kotlin.rules`/`detekt.compose.rules` no longer require a catalog alias + +These 3 slots now fall back to a coordinate baked into the plugin when the consumer's `libs` +catalog has no matching alias (or no catalog at all) — previously this was a hard failure +("MISSING KTLINT/DETEKT DEPENDENCY IN VERSION CATALOG" / "MISSING VERSION CATALOG"). A +matching alias in your own catalog, if present, still wins unchanged — **no action required** +for existing projects with the standard `ktlint-cli`/`detekt-formatting`/`detekt-compose-rules` +aliases already declared. + +### New: unified dependency slots (`from(...)` from any source + `useDefaults`) + +Every external dependency of the plugin — the ktlint CLI and each detekt platform's rule +sets — is now ONE uniform slot accepting every source kind: + +```kotlin +appQualityFoundation { + ktlint.cli { + from(deps.ktlint.cli) // typed accessor from ANY catalog + from("com.pinterest.ktlint:ktlint-cli:1.8.0") // string coordinates + from(files("tools/ktlint-cli.jar")) // checked-in jar files + useDefaults.set(false) // drop the `libs` catalog default + } + detekt.kotlin.rules { from(files("libs/detekt-rules-1.4.0.jar")) } + detekt.compose.rules { + from("ru.kode:detekt-rules-compose:1.4.0") // published custom rules + useDefaults.set(false) + } +} +``` + +Semantics: +- `from(...)` is add-only; entries from all sources accumulate. +- The slot's default (the `libs` catalog aliases `ktlint-cli`, `detekt-formatting`, + `detekt-compose-rules`) is independent of user additions and included while + `useDefaults` is `true` (the default) — so adding your custom rules jar keeps the + default formatting rules unless you disable them. +- Zero-config projects with the standard `libs` aliases need NO changes beyond the rules-jar + migration above. + +### Behavior change: configured-but-missing files fail the build + +A file listed in any slot (`from(files(...))`) that does not exist on disk fails the build +with an explanatory message naming the slot. Note the validation fires whenever the +dependency set is realized — including IDE sync and the `dependencies` report — not only on +task execution. + +### Breaking: `additionalSourcePatterns`/`additionalIgnoredSourcePatterns` (ktlint) and +`additionalSourcePaths`/`additionallyExcludedPaths` (detekt) replaced by `sources { }` + +Both blocks' raw `ListProperty` source-pattern properties are replaced by a single +`sources { }` block, mirroring the dependency slots' `include`/`exclude`/`useDefaults` shape. +The leaky `!` prefix ktlint ignores required is gone — `exclude` now takes bare patterns; the +plugin adds the CLI's `!` prefix internally. + +```kotlin +// before (1.0.8): +appQualityFoundation { + ktlint { + additionalSourcePatterns.set(listOf("**/src/*/kotlin/**/*.kts")) + additionalIgnoredSourcePatterns.set(listOf("!**/build-logic/**")) + } + detekt { + additionalSourcePaths.set(listOf("src/custom/kotlin")) + additionallyExcludedPaths.set(listOf("tmpGenerated")) + } +} + +// after: +appQualityFoundation { + ktlint.sources { + include.set(listOf("**/src/*/kotlin/**/*.kts")) + exclude.set(listOf("**/build-logic/**")) // no `!` prefix + } + detekt.sources { + include.set(listOf("src/custom/kotlin")) + exclude.set(listOf("tmpGenerated")) + } +} +``` + +`useDefaults.set(false)` on either block drops the plugin's bundled defaults (ktlint's +default Kotlin globs/ignore list, detekt's default per-platform source dirs) — same +`useDefaults` semantics as the dependency slots. + +### New: `detekt.baseline`, `detekt.xmlReportEnabled`, `detekt.sarifReportEnabled` + +Three new opt-in detekt configuration properties for incremental adoption and report format control: + +- `baseline`: optional detekt baseline file (e.g. `detekt-baseline.xml`). Findings present in the + baseline are suppressed. Unset by default (no baseline). +- `xmlReportEnabled`: emit detekt's XML report per task. Default `false`. +- `sarifReportEnabled`: emit detekt's SARIF report per task (e.g. for GitHub code scanning). Default `false`. + +Example: + +```kotlin +appQualityFoundation { + detekt { + baseline.set(layout.projectDirectory.file("detekt-baseline.xml")) + xmlReportEnabled.set(true) + sarifReportEnabled.set(false) + } +} +``` + +### New: opt-in `androidLint.enabled` wiring + +Integrate Android Gradle Plugin lint checks into `pipelineCheck` and `prePushCheck` aggregate tasks via +the new `androidLint { enabled.set(true) }` config. Off by default — lint is slow and most projects +already run it separately in CI. + +Example: + +```kotlin +appQualityFoundation { + androidLint { + enabled.set(true) + } +} +``` + +### Upgrade checklist for KODE projects + +1. Replace every `detekt..rulesPluginJar.set(...)` with + `detekt..rules { from(files(...)) }`. +2. If your project relied on the implicit `libs/detekt-rules-1.4.0.jar` pickup with a + **custom** detekt config (not the plugin's bundled `default.android-config.yml`), add the + same `rules { from(files(...)) }` line. Projects using the bundled android config need no + action — see "New: bundled default for `detekt.android.rules`" above. +3. The `libs` catalog aliases (`ktlint-cli`, `detekt-formatting`, `detekt-compose-rules`) are + now optional — only needed if you want a version different from the plugin's own baked-in + default, or to disable a default entirely with `useDefaults.set(false)`. +4. Replace `ktlint.additionalSourcePatterns`/`additionalIgnoredSourcePatterns` and + `detekt.additionalSourcePaths`/`additionallyExcludedPaths` with `ktlint.sources { }` / + `detekt.sources { }` as shown above — drop the `!` prefix from any exclude pattern. +5. Run `./gradlew pipelineCheck` and check CI is green. +6. If your project only applies the plugin at the root and configures `verboseLogging` (the + shape used by every current adopter) — no action needed. That zero-config shape is now + covered by an explicit test (`RealProjectShapeTest`) and needs no changes to keep working. diff --git a/README.md b/README.md index 16879df..2d76bcd 100644 --- a/README.md +++ b/README.md @@ -20,24 +20,28 @@ It configures Detekt for eligible modules, runs `ktlint` through CLI, and provid - Version catalog named `libs` - If the plugin is applied directly to an Android module project: Android Gradle Plugin `7.4.0+` and `com.android.application` -### Required `libs.versions.toml` entries +### Optional `libs.versions.toml` entries -The plugin looks up these aliases in the `libs` catalog: +The plugin looks up these aliases in the `libs` catalog. None are required — any alias +missing (or the catalog itself missing) falls back to the plugin's bundled default version: ```toml [versions] detekt = "1.23.8" ktlintCli = "1.8.0" -detektComposeRules = "1.2.2" +detektComposeRules = "1.4.0" [libraries] -ktlint-cli = { module = "com.pinterest:ktlint-cli", version.ref = "ktlintCli" } +ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlintCli" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-compose-rules = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" } ``` `detekt-compose-rules` is required only for modules using Compose Detekt rules. +Catalog lookups are lazy: a missing alias fails the first task that needs it (with an +explanatory message), not the plugin apply, so unrelated tasks keep working. + ## Installation ### Plugin Portal @@ -58,7 +62,7 @@ In root `build.gradle.kts`: ```kotlin plugins { - id("ru.kode.android.app-quality.foundation") version "1.0.8" + id("ru.kode.android.app-quality.foundation") version "2.0.0" } ``` @@ -96,12 +100,23 @@ Pre-push formatting + static analysis: ## Tasks -- `gitHooksSetup`: runs `git config core.hooksPath ` (default `/.githooks`) -- `ktlintCheck`: runs ktlint checks for Kotlin sources +- `gitHooksSetup`: runs `git config core.hooksPath ` (default `/.githooks`); + skipped automatically when the root project is not a git repository, or when + `gitHooksEnabled` is set to `false` +- `ktlintCheck`: runs ktlint checks for Kotlin sources (up-to-date aware: skipped when + sources and config did not change) - `ktlintFormat`: runs ktlint auto-format for Kotlin sources -- `pipelineCheck`: depends on `gitHooksSetup`, `ktlintCheck`, and eligible Detekt tasks -- `prePushCheck`: depends on `gitHooksSetup`, `ktlintFormat`, and eligible Detekt tasks +- `pipelineCheck`: depends on `gitHooksSetup`, `ktlintCheck`, `detektCheck` (if eligible modules exist), and + `androidLintCheck` (if `androidLint.enabled` is `true`) +- `prePushCheck`: depends on `gitHooksSetup`, `ktlintFormat`, `detektCheck` (if eligible modules exist), and + `androidLintCheck` (if `androidLint.enabled` is `true`) +- `androidLintCheck`: runs Android Gradle Plugin lint checks; skipped unless `androidLint.enabled` is `true` - `printRequiredGradleJvmargs`: prints the current Gradle JVM input arguments +- `generateDefaultDetektKotlinConfig` / `...AndroidConfig` / `...ComposeConfig` / `...AndroidRulesJar` / + `generateDefaultKtlintEditorconfig`: materialize bundled default configs and resources into + `/build/app-quality/`; run automatically only when a default is actually used. The rules jar + (`generateDefaultDetektAndroidRulesJar`) contains the bundled KODE Android detekt ruleset and is placed + under `/build/app-quality/detekt/rules/` ## Configuration @@ -120,22 +135,33 @@ appQualityFoundation { verboseLogging.set(false) jvmTarget.set(JvmTarget.JVM_17) gitHooks.set(rootProject.layout.projectDirectory.file(".githooks")) + gitHooksEnabled.set(true) // set false to opt out of git hooks setup entirely ktlint { projectConfig.set(rootProject.layout.projectDirectory.file(".editorconfig")) - additionalSourcePatterns.set(listOf("**/src/*/kotlin/**/*.kts")) - additionalIgnoredSourcePatterns.set(listOf("!**/build-logic/**")) + sources { + include.set(listOf("**/src/*/kotlin/**/*.kts")) + exclude.set(listOf("**/build-logic/**")) + // Also available as vararg sugar: include("**/src/*/kotlin/**/*.kts"); exclude("**/build-logic/**") + } } detekt { ignoredBuildTypes.set(listOf("release", "internal", "external", "demo")) - additionallyExcludedPaths.set(listOf("tmpGenerated")) - additionalSourcePaths.set(listOf("src/custom/kotlin")) + sources { + include.set(listOf("src/custom/kotlin")) + exclude.set(listOf("tmpGenerated")) + } typeResolution.set(false) + baseline.set(layout.projectDirectory.file("detekt-baseline.xml")) + xmlReportEnabled.set(true) + sarifReportEnabled.set(false) kotlin { projectConfig.set(layout.projectDirectory.file("detekt-kotlin-config.yml")) - rulesPluginJar.set(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar")) + rules { + from(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar")) + } } android { @@ -146,6 +172,10 @@ appQualityFoundation { projectConfig.set(layout.projectDirectory.file("detekt-compose-config.yml")) } } + + androidLint { + enabled.set(true) + } } ``` @@ -156,44 +186,111 @@ appQualityFoundation { | `verboseLogging` | `false` | | `jvmTarget` | `JVM_17` | | `gitHooks` | `/.githooks` | -| `ktlint.additionalSourcePatterns` | `[]` | -| `ktlint.additionalIgnoredSourcePatterns` | `[]` | +| `gitHooksEnabled` | `true` | +| `ktlint.sources.include` | `["**/src/*/java/**/*.kt", "**/src/*/kotlin/**/*.kt"]` (while `useDefaults` is `true`) | +| `ktlint.sources.exclude` | `["**/build/**", "**/generated/**", "**/templates/**", "**/src/test/**", "**/src/androidTest/**", "**/src/commonTest/**", "templates/**", "**/schema/**/*.kt"]` (while `useDefaults` is `true`) | | `detekt.ignoredBuildTypes` | `["release", "internal", "external", "demo"]` | -| `detekt.additionallyExcludedPaths` | `[]` | -| `detekt.additionalSourcePaths` | `[]` | +| `detekt.sources.include` | per-platform Kotlin/Java source dirs (while `useDefaults` is `true`) | +| `detekt.sources.exclude` | `[]` | | `detekt.typeResolution` | `false` | -| `detekt.kotlin.rulesPluginJar` | `/libs/detekt-rules-1.4.0.jar` | -| `detekt.kotlin.rulesLibraries` | `libs.detekt-formatting` | -| `detekt.compose.rulesLibraries` | `libs.detekt-compose-rules` | +| `detekt.baseline` | unset (no baseline) | +| `detekt.xmlReportEnabled` | `false` | +| `detekt.sarifReportEnabled` | `false` | +| `androidLint.enabled` | `false` | +| `ktlint.cli` | `libs.ktlint-cli`, falling back to the plugin's own baked-in `com.pinterest.ktlint:ktlint-cli` coordinate if no matching catalog alias exists (while `useDefaults` is `true`) | +| `detekt.kotlin.rules` | `libs.detekt-formatting`, falling back to the plugin's own baked-in `io.gitlab.arturbosch.detekt:detekt-formatting` coordinate if no matching catalog alias exists (while `useDefaults` is `true`) | +| `detekt.android.rules` | the plugin's bundled KODE Android rules jar (not published anywhere externally — see [MIGRATION.md](MIGRATION.md)) (while `useDefaults` is `true`) | +| `detekt.compose.rules` | `libs.detekt-compose-rules`, falling back to the plugin's own baked-in `ru.kode:detekt-rules-compose` coordinate if no matching catalog alias exists (while `useDefaults` is `true`) | + +### Configuring dependencies + +Every external dependency of the plugin lives in a uniform slot (`ktlint.cli`, +`detekt..rules`) configurable from ANY source through one `from(...)` API — +version-catalog accessors, string coordinates (e.g. your own published rule sets), or jar +files. Additions always stack ON TOP of the slot's default; disable the default with +`useDefaults.set(false)`. + +For `ktlint.cli`/`detekt.kotlin.rules`/`detekt.compose.rules`, a matching alias in your own +`libs` catalog (if present) always wins; the plugin's baked-in coordinate is only a fallback, +so the plugin works with zero catalog setup too. `detekt.android.rules` has no catalog-alias +option at all — its default is bundled directly in the plugin (the jar isn't published to any +Maven repo). See [MIGRATION.md](MIGRATION.md) for upgrade notes. + +```kotlin +appQualityFoundation { + ktlint.cli { + from(deps.ktlint.cli) // typed accessor from any catalog + // from("com.pinterest.ktlint:ktlint-cli:1.8.0") // or coordinates + // from(fileTree("tools/ktlint") { include("*.jar") }) // or checked-in jars + useDefaults.set(false) // drop the `libs` catalog default + } + detekt.kotlin.rules { + from(files("libs/detekt-rules-1.4.0.jar")) // stacks on detekt-formatting + } + detekt.compose.rules { + from("ru.kode:detekt-rules-compose:1.4.0") // published custom rules + useDefaults.set(false) // replace the default entirely + } +} +``` + +A configured-but-missing file in any slot fails the build with an explanatory message +naming the slot. + +All configuration blocks (`ktlint { }`, `detekt { }`, `detekt.kotlin { }`, `cli { }`, +`rules { }`) have both `Action` and Groovy `Closure` overloads, so the same block syntax +works identically in `build.gradle.kts` and Groovy `build.gradle` scripts. ### Config file discovery and fallback -If project config files are missing, the plugin uses bundled defaults copied into build directories: - -- Ktlint: - - Project file lookup: `/.editorconfig` (or configured path) - - Fallback: `/build/ktlint/.editorconfig` -- Detekt Kotlin: - - Project file lookup: `/detekt-kotlin-config.yml` - - Fallback: `/build/detekt/kotlin-config.yml` -- Detekt Android: - - Project file lookup: `/detekt-android-config.yml` - - Fallback: `/build/detekt/android-config.yml` -- Detekt Compose: - - Project file lookup: `/detekt-compose-config.yml` - - Fallback: `/build/detekt/compose-config.yml` +Detekt configs are resolved and merged **per module**: each module independently walks the +priority chain for every platform layer that applies to it (see Module Coverage below): + +1. Extension override — set once at the root, forces that file for ALL modules +2. Module-local file — e.g. `/detekt-kotlin-config.yml`, lets a module customize + its own rules; other modules are unaffected +3. Bundled default — used by any module without an override or a local file + +Only the *storage location* of the bundled defaults is root-level: since their content is +identical for every module, they are generated once under `/build/app-quality/` by +dedicated tasks instead of being copied into every module. Nothing is written at +configuration time, so the configuration cache stays reusable and creating a module file +later is picked up correctly. + +- Ktlint (runs once at the root over all modules, so its whole chain is root-level): + - Extension override: `ktlint.projectConfig` + - Project file lookup: `/.editorconfig` + - Bundled default (generated): `/build/app-quality/ktlint/.editorconfig` +- Detekt Kotlin (per module): + - Extension override: `detekt.kotlin.projectConfig` + - Module file lookup: `/detekt-kotlin-config.yml` + - Bundled default (generated): `/build/app-quality/detekt/kotlin-config.yml` +- Detekt Android (per module): + - Extension override: `detekt.android.projectConfig` + - Module file lookup: `/detekt-android-config.yml` + - Bundled default (generated): `/build/app-quality/detekt/android-config.yml` +- Detekt Compose (per module): + - Extension override: `detekt.compose.projectConfig` + - Module file lookup: `/detekt-compose-config.yml` + - Bundled default (generated): `/build/app-quality/detekt/compose-config.yml` + +`detekt.kotlin.rules`, `detekt.compose.rules`, and `ktlint.cli` fall back to a baked-in +coordinate default when no matching `libs` catalog alias exists (see Defaults above). +`detekt.android.rules` has its own bundled-jar default, since it isn't published anywhere +externally. A configured-but-missing file in any slot fails the build with an explanatory +message naming the slot (see "Configuring dependencies" above). ## Module Coverage -Detekt configuration is applied for subprojects that use any of: +Detekt is applied only to subprojects that use a matching plugin (plain Java modules are +left untouched). Config layers merged per module: -- `org.jetbrains.kotlin.jvm` -- `org.jetbrains.kotlin.multiplatform` -- `org.jetbrains.kotlin.android` -- `com.android.application` -- `com.android.library` -- `org.jetbrains.compose` -- `org.jetbrains.kotlin.plugin.compose` +- Kotlin config — `org.jetbrains.kotlin.jvm`, `org.jetbrains.kotlin.multiplatform`, + `org.jetbrains.kotlin.android`, `com.android.application`, `com.android.library` +- Android config (additionally) — `org.jetbrains.kotlin.android`, + `com.android.application`, `com.android.library` +- Compose config (additionally) — `org.jetbrains.compose`, + `org.jetbrains.kotlin.plugin.compose` ## Development in This Repository diff --git a/build-conventions/build.gradle b/build-conventions/build.gradle index 48b90e0..0cec32c 100644 --- a/build-conventions/build.gradle +++ b/build-conventions/build.gradle @@ -16,6 +16,5 @@ dependencies { // can be applied in a precompiled script plugin" implementation libs.kotlin.plugin implementation libs.detekt.plugin - implementation libs.ksp.plugin implementation libs.ktlint.plugin } diff --git a/build-conventions/src/main/groovy/kotlin-convention.gradle b/build-conventions/src/main/groovy/kotlin-convention.gradle index 18e6b30..991bf5a 100644 --- a/build-conventions/src/main/groovy/kotlin-convention.gradle +++ b/build-conventions/src/main/groovy/kotlin-convention.gradle @@ -19,8 +19,6 @@ kotlin { tasks.withType(KotlinCompile).configureEach { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) - freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") - freeCompilerArgs.add("-opt-in=kotlinx.coroutines.FlowPreview") freeCompilerArgs.add("-opt-in=kotlin.contracts.ExperimentalContracts") freeCompilerArgs.add("-Xopt-in=kotlin.ExperimentalStdlibApi") } diff --git a/build.gradle.kts b/build.gradle.kts index d0343a8..338a7ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.publish) apply false alias(libs.plugins.kotlin) apply false - alias(libs.plugins.grgit) apply false } val dependsOnRecursivelyByName = { task: Task, name: String -> diff --git a/example-project/build.gradle.kts b/example-project/build.gradle.kts index 4bd97f2..8bd69d8 100644 --- a/example-project/build.gradle.kts +++ b/example-project/build.gradle.kts @@ -2,25 +2,20 @@ plugins { alias(libs.plugins.kotlin) apply false alias(libs.plugins.agp) apply false - alias(libs.plugins.grgit) apply false - alias(libs.plugins.firebaseAppdistribution) apply false alias(libs.plugins.publish) apply false id("ru.kode.android.app-quality.foundation") } -tasks.register("assemble", Delete::class.java) { - dependsOnRecursivelyByName(this, "assemble") -} - -val dependsOnRecursivelyByName = { task: Task, name: String -> - subprojects { - this.tasks.matching { it.name == name }.forEach { t -> - task.dependsOn(t) - } +tasks.register("assemble") { + subprojects.forEach { subproject -> + dependsOn(subproject.tasks.matching { it.name == "assemble" }) } } appQualityFoundation { verboseLogging.set(true) - ktlint.projectConfig.set(rootProject.file(".editorconfig")) + ktlint.projectConfig.set(rootProject.layout.projectDirectory.file(".editorconfig")) + detekt.kotlin.rules { + from(files(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar"))) + } } diff --git a/gradle.properties b/gradle.properties index b0aad44..7503d3e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,5 @@ kotlin.code.style=official org.gradle.jvmargs=-Xmx1536m org.gradle.parallel=true org.gradle.configuration-cache=true + +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b12dab2..f763a40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,16 @@ [versions] -appQualityPlugin = "1.0.8" -kotlin = "2.3.20" -agp = "9.0.1" +appQualityPlugin = "2.0.0" +kotlin = "2.4.10" +agp = "9.3.1" detekt = "1.23.8" ktlint = "12.1.2" -firebaseAppdistribution = "5.2.1" grgit = "5.3.3" publish = "2.1.1" -okhttp = "5.3.2" -retrofit = "3.0.0" -junit = "6.0.3" -ksp = "2.3.6" -kotlinxSerialization = "1.10.0" -vanniktechMavenPublish = "0.36.0" +junit = "6.1.3" ktlintCli = "1.8.0" detekt-compose-rules = "1.4.0" -buildPublishCore = "1.2.8" +buildPublishCore = "2.1.1" +composeMultiplatform = "1.11.1" [libraries] detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } @@ -23,25 +18,18 @@ detekt-compose-rules = { module = "ru.kode:detekt-rules-compose", version.ref = ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlintCli" } agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } -okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } grgitCore = { module = "org.ajoberstar.grgit:grgit-core", version.ref = "grgit" } -grgitGradle = { module = "org.ajoberstar.grgit:grgit-gradle", version.ref = "grgit" } junitBom = { module = "org.junit:junit-bom", version.ref = "junit" } -serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } plugin-core = { group = "ru.kode.android", name = "build-publish-novo-core", version.ref = "buildPublishCore" } +plugin-foundation = { group = "ru.kode.android", name = "app-quality-foundation", version.ref = "appQualityPlugin" } kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +compose-compiler-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +compose-multiplatform-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "composeMultiplatform" } ktlint-plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } detekt-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } -ksp-plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } [plugins] agp = { id = "com.android.application", version.ref = "agp" } -firebaseAppdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppdistribution" } -grgit = { id = "org.ajoberstar.grgit", version.ref = "grgit" } publish = { id = "com.gradle.plugin-publish", version.ref = "publish" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 100644 GIT binary patch literal 47505 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f50f69e..69dd0d0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd3..a51ec4f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,19 +13,22 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,15 +43,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -56,34 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts index bfb5b83..bdff87a 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -2,8 +2,6 @@ plugins { alias(libs.plugins.kotlin) apply false alias(libs.plugins.publish) apply false - alias(libs.plugins.ksp) apply false - alias(libs.plugins.serialization) apply false } allprojects { diff --git a/plugin-build/plugin-foundation/build.gradle.kts b/plugin-build/plugin-foundation/build.gradle.kts index 974388a..8ff2be6 100644 --- a/plugin-build/plugin-foundation/build.gradle.kts +++ b/plugin-build/plugin-foundation/build.gradle.kts @@ -3,7 +3,6 @@ plugins { id("plugin-convention") id("java-gradle-plugin") id("com.gradle.plugin-publish") - id("com.google.devtools.ksp") } base { @@ -12,13 +11,27 @@ base { dependencies { implementation(gradleApi()) - implementation(libs.grgitCore) - implementation(libs.grgitGradle) implementation(libs.detekt.plugin) compileOnly(libs.agp) } +val versionCatalog = extensions.getByType().named("libs") +val generateDefaultToolVersions = + tasks.register("generateDefaultToolVersions") { + destinationFile.set(layout.buildDirectory.file("generated/resources/default-tool-versions.properties")) + listOf("ktlint-cli", "detekt-formatting", "detekt-compose-rules").forEach { alias -> + val library = versionCatalog.findLibrary(alias).get().get() + property(alias, "${library.module.group}:${library.module.name}:${library.versionConstraint.requiredVersion}") + } + } + +sourceSets { + main { + resources.srcDir(generateDefaultToolVersions.map { it.destinationFile.get().asFile.parentFile }) + } +} + gradlePlugin { website.set("https://github.com/appKODE/app-quality-plugin") vcsUrl.set("https://github.com/appKODE/app-quality-plugin") @@ -26,11 +39,13 @@ gradlePlugin { plugins { create("ru.kode.android.app-quality.foundation") { id = "ru.kode.android.app-quality.foundation" - displayName = "Configure project output using tag and generate changelog" + displayName = "Centralized ktlint and detekt configuration for Android/Kotlin projects" implementationClass = "ru.kode.android.app.quality.plugin.foundation.AppQualityFoundationPlugin" version = project.version - description = "Android plugin to configure output and changelog generation" - tags.set(listOf("output", "publish", "changelog", "build")) + description = + "Gradle plugin that centralizes static analysis and formatting: configures detekt per module, " + + "runs ktlint through CLI and provides aggregate verification tasks (pipelineCheck, prePushCheck)" + tags.set(listOf("quality", "detekt", "ktlint", "static-analysis", "verification")) } } } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksWiring.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksWiring.kt new file mode 100644 index 0000000..d6b8a56 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksWiring.kt @@ -0,0 +1,86 @@ +@file:Suppress("MatchingDeclarationName") // file groups the aggregate-task wiring fun with its small result type + +package ru.kode.android.app.quality.plugin.foundation + +import io.gitlab.arturbosch.detekt.Detekt +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import ru.kode.android.app.quality.plugin.foundation.extension.AppQualityFoundationExtension +import ru.kode.android.app.quality.plugin.foundation.task.GitHooksSetupTask +import ru.kode.android.build.publish.plugin.core.logger.LoggerService +import java.lang.management.ManagementFactory + +internal fun Project.configureGitHooksSetup(extension: AppQualityFoundationExtension): TaskProvider { + return tasks.register("gitHooksSetup", GitHooksSetupTask::class.java) { task -> + task.hooksPath.set(extension.gitHooks.map { it.asFile.path }) + task.rootDir.set(rootProject.layout.projectDirectory) + // Capture only the Provider, not `extension` itself — the extension also holds + // dependency-slot config (FileCollection/Dependency) that can't be configuration-cache + // serialized, and onlyIf closures are stored as part of the cached task graph. + val enabled = extension.gitHooksEnabled + task.onlyIf("git hooks setup is enabled") { enabled.get() } + } +} + +internal fun configurePrintRequiredGradleJvmargs(project: Project) { + project.tasks.register("printRequiredGradleJvmargs") { task -> + task.doLast { + val args = + ManagementFactory.getRuntimeMXBean() + .inputArguments + .joinToString(" ") + // Need to print into console each time, no need to use logger + println("Args: $args") + } + } +} + +/** + * Wires the aggregate tasks to subproject detekt tasks through live, lazily filtered task + * collections: the dependencies resolve at task-graph time, after all subprojects evaluate, + * so late-registered variant tasks are included and nothing is realized eagerly. + */ +internal fun Project.configureAggregateTasks( + extension: AppQualityFoundationExtension, + gitHooksSetup: TaskProvider, + ktlintTasks: KtlintTasks, + loggerProvider: Provider, +): AggregateTasks { + val ignoredBuildTypes = extension.detekt.ignoredBuildTypes + + val pipelineCheck = + tasks.register("pipelineCheck") { task -> + task.usesService(loggerProvider) + task.group = "verification" + task.description = "Runs git hooks setup, ktlint check and detekt on all modules" + task.dependsOn(gitHooksSetup, ktlintTasks.check) + } + val prePushCheck = + tasks.register("prePushCheck") { task -> + task.usesService(loggerProvider) + task.group = "verification" + task.description = "Runs git hooks setup, ktlint format and detekt on all modules" + task.dependsOn(gitHooksSetup, ktlintTasks.format) + } + + subprojects { subproject -> + val detektTasks = + subproject.tasks.withType(Detekt::class.java).matching { task -> + ignoredBuildTypes.get().none { ignored -> task.name.contains(ignored, ignoreCase = true) } + } + pipelineCheck.configure { it.dependsOn(detektTasks) } + prePushCheck.configure { it.dependsOn(detektTasks) } + subproject.tasks.withType(Detekt::class.java).configureEach { task -> + task.mustRunAfter(gitHooksSetup, ktlintTasks.check, ktlintTasks.format) + } + } + + return AggregateTasks(pipelineCheck, prePushCheck) +} + +internal data class AggregateTasks( + val pipelineCheck: TaskProvider, + val prePushCheck: TaskProvider, +) diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AndroidLintWiring.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AndroidLintWiring.kt new file mode 100644 index 0000000..36b2d19 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AndroidLintWiring.kt @@ -0,0 +1,32 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.api.Project +import ru.kode.android.app.quality.plugin.foundation.extension.AppQualityFoundationExtension + +private val ANDROID_LINT_TASK_NAME_REGEX = Regex("^lint([A-Z].*)?$") + +/** + * Opt-in wiring of AGP's own `lint`/`lint` tasks into `pipelineCheck`/`prePushCheck`, + * mirroring the detekt aggregate-task wiring shape (per-subproject `withType` + lazy + * `dependsOn`). Exists mainly to prove the plugin's aggregate-task pattern can take on a new + * tool without touching detekt/ktlint code — off by default via [AppQualityFoundationExtension + * .androidLint], since lint is slow and most consumers already run it separately in CI. + */ +internal fun Project.configureAndroidLint( + extension: AppQualityFoundationExtension, + aggregateTasks: AggregateTasks, +) { + val enabled = extension.androidLint.enabled + val ignoredBuildTypes = extension.detekt.ignoredBuildTypes + + subprojects { subproject -> + val lintTasks = + subproject.tasks.matching { task -> + enabled.get() && + ANDROID_LINT_TASK_NAME_REGEX.matches(task.name) && + ignoredBuildTypes.get().none { ignored -> task.name.contains(ignored, ignoreCase = true) } + } + aggregateTasks.pipelineCheck.configure { it.dependsOn(lintTasks) } + aggregateTasks.prePushCheck.configure { it.dependsOn(lintTasks) } + } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AppQualityFoundationPlugin.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AppQualityFoundationPlugin.kt index 85a9773..9cabc86 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AppQualityFoundationPlugin.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/AppQualityFoundationPlugin.kt @@ -1,44 +1,14 @@ package ru.kode.android.app.quality.plugin.foundation -import io.gitlab.arturbosch.detekt.Detekt -import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask -import io.gitlab.arturbosch.detekt.DetektPlugin -import io.gitlab.arturbosch.detekt.extensions.DetektExtension -import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.artifacts.ExternalModuleDependency -import org.gradle.api.artifacts.FileCollectionDependency -import org.gradle.api.artifacts.type.ArtifactTypeDefinition -import org.gradle.api.attributes.Bundling -import org.gradle.api.attributes.Category -import org.gradle.api.attributes.LibraryElements -import org.gradle.api.attributes.Usage -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Exec -import org.gradle.api.tasks.JavaExec -import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile -import ru.kode.android.app.quality.plugin.foundation.config.DetektConfig -import ru.kode.android.app.quality.plugin.foundation.config.KtlintConfig -import ru.kode.android.app.quality.plugin.foundation.config.PlatformDetektConfig import ru.kode.android.app.quality.plugin.foundation.extension.AppQualityFoundationExtension -import ru.kode.android.app.quality.plugin.foundation.messages.noDetektRulesDependencyReferenceInLibsMessage -import ru.kode.android.app.quality.plugin.foundation.messages.noEditorConfigFileMessage -import ru.kode.android.app.quality.plugin.foundation.messages.noKtlintDependencyReferenceInLibsMessage -import ru.kode.android.app.quality.plugin.foundation.utils.ignoredSourcePatterns -import ru.kode.android.app.quality.plugin.foundation.utils.kotlinSourcePatterns -import ru.kode.android.app.quality.plugin.foundation.utils.libs -import ru.kode.android.app.quality.plugin.foundation.utils.resolveFile import ru.kode.android.app.quality.plugin.foundation.validate.stopExecutionIfNotSupported import ru.kode.android.build.publish.plugin.core.logger.LOGGER_SERVICE_EXTENSION_NAME import ru.kode.android.build.publish.plugin.core.logger.LOGGER_SERVICE_NAME import ru.kode.android.build.publish.plugin.core.logger.LoggerService import ru.kode.android.build.publish.plugin.core.logger.LoggerServiceExtension import ru.kode.android.build.publish.plugin.core.util.serviceName -import java.io.File -import java.lang.management.ManagementFactory const val APP_QUALITY_EXTENSION_NAME = "appQualityFoundation" @@ -50,6 +20,9 @@ abstract class AppQualityFoundationPlugin : Plugin { project.extensions .create(APP_QUALITY_EXTENSION_NAME, AppQualityFoundationExtension::class.java) + val defaultConfigs = project.registerDefaultConfigTasks() + project.configureConventions(extension, defaultConfigs) + val loggerServiceProvider = project.gradle.sharedServices.registerIfAbsent( project.serviceName(LOGGER_SERVICE_NAME), @@ -59,556 +32,23 @@ abstract class AppQualityFoundationPlugin : Plugin { it.parameters.bodyLogging.set(false) } - val versionCatalogName = project.provider { "libs" } - project.extensions.create( LOGGER_SERVICE_EXTENSION_NAME, LoggerServiceExtension::class.java, loggerServiceProvider, ) - project.configureSubprojectsDetekt( - versionCatalogName, - extension, - loggerServiceProvider, - ) + project.configureSubprojectsDetekt(extension, loggerServiceProvider, defaultConfigs) val gitHooksSetup = project.configureGitHooksSetup(extension) - val (ktlintCheck, ktlintFormat) = + val ktlintTasks = project.configureKtlint( - versionCatalogName, extension.ktlint, loggerServiceProvider, + defaultConfigs.editorconfig, ) configurePrintRequiredGradleJvmargs(project) - val detektTasks = project.detektTasks(extension.detekt) - project.configurePipelineCheck( - gitHooksSetup, - detektTasks, - ktlintCheck, - loggerServiceProvider, - ) - project.configurePrePushCheck( - gitHooksSetup, - detektTasks, - ktlintFormat, - loggerServiceProvider, - ) - } -} - -private fun Project.configurePrePushCheck( - gitHooksSetup: TaskProvider, - detektTasks: List, - ktlintFormat: TaskProvider, - loggerProvider: Provider, -) { - tasks.register("prePushCheck") { task -> - task.usesService(loggerProvider) - group = "verification" - - task.dependsOn(gitHooksSetup) - task.dependsOn(ktlintFormat) - task.dependsOn(detektTasks) - - detektTasks.forEach { detekt -> - detekt.mustRunAfter(gitHooksSetup, ktlintFormat) - } - } -} - -private fun Project.configurePipelineCheck( - gitHooksSetup: TaskProvider, - detektTasks: List, - ktlintCheck: TaskProvider, - loggerProvider: Provider, -) { - tasks.register("pipelineCheck") { task -> - task.usesService(loggerProvider) - group = "verification" - - task.dependsOn(gitHooksSetup) - task.dependsOn(ktlintCheck) - task.dependsOn(detektTasks) - - detektTasks.forEach { detekt -> - detekt.mustRunAfter(gitHooksSetup, ktlintCheck) - } - } -} - -private fun Project.detektTasks(detektConfig: DetektConfig): List { - val detektIgnoredBuildTypes = detektConfig.ignoredBuildTypes.get() - return subprojects.flatMap { subproject -> - subproject.tasks.withType(Detekt::class.java) - .matching { task -> - detektIgnoredBuildTypes.none { task.name.contains(it, ignoreCase = true) } - } - } -} - -private fun configurePrintRequiredGradleJvmargs(project: Project) { - project.tasks.register("printRequiredGradleJvmargs") { task -> - task.doLast { - val args = - ManagementFactory.getRuntimeMXBean() - .inputArguments - .joinToString(" ") - // Need to print into console each time, no need to use logger - println("Args: $args") - } - } -} - -private fun Project.configureGitHooksSetup(extension: AppQualityFoundationExtension): TaskProvider { - val hooksPath = - extension.gitHooks.getOrElse { - project.rootProject.file(".githooks") - }.asFile - return tasks.register("gitHooksSetup", Exec::class.java) { task -> - task.executable = "sh" - task.args = listOf("-c", "git config core.hooksPath ${hooksPath.path}") - task.description = "Changing hookspath to project .githooks" - } -} - -private fun Project.configureKtlint( - versionCatalogName: Provider, - config: KtlintConfig, - loggerServiceProvider: Provider, -): KtlintTasks { - val ktlintCliLibraryName = "ktlint-cli" - - val ktlintCli = configurations.create("ktlintCli") - val ktlintLibraryName = - config.cliLibrary - .convention( - libs(versionCatalogName.get()) - .findLibrary(ktlintCliLibraryName.replace("-", ".")) - .orElseThrow { - GradleException(noKtlintDependencyReferenceInLibsMessage(ktlintCliLibraryName)) - }, - ) - .get() - - val additionalIgnoredSourcePatterns = config.additionalIgnoredSourcePatterns.get() - val additionalSourcePatterns = config.additionalSourcePatterns.get() - - val ktlintProjectConfigPath = - config.projectConfig.getOrElse { - rootProject.file(".editorconfig") - } - val ktlintFallbackFile = - layout.buildDirectory - .file("ktlint/.editorconfig") - .get() - val ktlintDefaultFile = "ktlint/default.editorconfig" - - val editorConfigProvider = - resolveFile( - ktlintProjectConfigPath, - ktlintFallbackFile, - ktlintDefaultFile, - loggerServiceProvider, - ) - - dependencies.add(ktlintCli.name, ktlintLibraryName) - - ktlintCli.attributes { attrs -> - attrs.attribute( - ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, - ArtifactTypeDefinition.JAR_TYPE, - ) - attrs.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME), - ) - attrs.attribute( - Bundling.BUNDLING_ATTRIBUTE, - objects.named(Bundling::class.java, Bundling.SHADOWED), - ) - attrs.attribute( - LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, - objects.named(LibraryElements::class.java, LibraryElements.JAR), - ) - attrs.attribute( - Category.CATEGORY_ATTRIBUTE, - objects.named(Category::class.java, Category.LIBRARY), - ) - } - - val ignoredSourcePatterns = ignoredSourcePatterns(additionalIgnoredSourcePatterns) - val kotlinSourcePatterns = kotlinSourcePatterns(additionalSourcePatterns) - - val ktlintCheck = - tasks.register("ktlintCheck", JavaExec::class.java) { task: JavaExec -> - task.usesService(loggerServiceProvider) - - task.group = "verification" - task.description = "Run ktlint check on all Android modules" - - task.classpath = ktlintCli - task.mainClass.set("com.pinterest.ktlint.Main") - - task.jvmArgs( - "--add-opens=java.base/java.lang=ALL-UNNAMED", - "--add-opens=java.base/java.util=ALL-UNNAMED", - ) - - task.doFirst { - val editorConfig = editorConfigProvider.get().asFile - - if (!editorConfig.exists()) { - throw GradleException(noEditorConfigFileMessage(editorConfig)) - } - - val editorConfigPath = editorConfig.absolutePath.replace('\\', '/') - - val logger = loggerServiceProvider.get() - logger.info("Use editor config for ktlintCheck = $editorConfigPath") - - task.args = listOf( - "--editorconfig=$editorConfigPath", - "--relative", - ) + ignoredSourcePatterns + kotlinSourcePatterns - } - } - - val ktlintFormat = - tasks.register("ktlintFormat", JavaExec::class.java) { task -> - task.usesService(loggerServiceProvider) - - task.group = "formatting" - task.description = "Run ktlint format on all Android modules" - - task.classpath = ktlintCli - task.mainClass.set("com.pinterest.ktlint.Main") - - task.jvmArgs( - "--add-opens=java.base/java.lang=ALL-UNNAMED", - "--add-opens=java.base/java.util=ALL-UNNAMED", - ) - - task.doFirst { - val editorConfig = editorConfigProvider.get().asFile - - if (!editorConfig.exists()) { - throw GradleException(noEditorConfigFileMessage(editorConfig)) - } - - val editorConfigPath = editorConfig.absolutePath.replace('\\', '/') - - val logger = loggerServiceProvider.get() - logger.info("Use editor config for ktlintFormat = $editorConfigPath") - - task.args = listOf( - "-F", - "--editorconfig=$editorConfigPath", - "--relative", - ) + ignoredSourcePatterns + kotlinSourcePatterns - } - } - return KtlintTasks( - check = ktlintCheck, - format = ktlintFormat, - ) -} - -private fun Project.configureSubprojectsDetekt( - versionCatalogName: Provider, - extension: AppQualityFoundationExtension, - loggerProvider: Provider, -) { - subprojects { subproject -> - subproject.configureProjectDetekt(versionCatalogName, extension, loggerProvider) - } -} - -private fun Project.configureProjectDetekt( - versionCatalogName: Provider, - extension: AppQualityFoundationExtension, - loggerProvider: Provider, -) { - listOf("org.jetbrains.kotlin.jvm", "org.jetbrains.kotlin.multiplatform", "com.android.library") - .forEach { pluginId -> - pluginManager.apply(DetektPlugin::class.java) - pluginManager.withPlugin(pluginId) { - val config = kotlinDetektConfig(versionCatalogName, extension, loggerProvider) - configureDetekt( - loggerProvider = loggerProvider, - verboseLogging = extension.verboseLogging, - config = config, - detektConfig = extension.detekt, - ) - } - } - - listOf("org.jetbrains.kotlin.android", "com.android.application") - .forEach { pluginId -> - pluginManager.apply(DetektPlugin::class.java) - pluginManager.withPlugin(pluginId) { - val kotlinConfig = kotlinDetektConfig(versionCatalogName, extension, loggerProvider) - configureDetekt( - loggerProvider = loggerProvider, - verboseLogging = extension.verboseLogging, - config = kotlinConfig, - detektConfig = extension.detekt, - ) - val androidConfig = androidDetektConfig(extension, loggerProvider) - configureDetekt( - loggerProvider = loggerProvider, - verboseLogging = extension.verboseLogging, - config = androidConfig, - detektConfig = extension.detekt, - ) - } - } - - listOf("org.jetbrains.compose", "org.jetbrains.kotlin.plugin.compose") - .forEach { pluginId -> - pluginManager.apply(DetektPlugin::class.java) - pluginManager.withPlugin(pluginId) { - val config = composeDetektConfig(versionCatalogName, extension, loggerProvider) - configureDetekt( - loggerProvider = loggerProvider, - verboseLogging = extension.verboseLogging, - config = config, - detektConfig = extension.detekt, - ) - } - } - - configureDetektTasks( - verboseLogging = extension.verboseLogging, - jvmTarget = extension.jvmTarget, - loggerProvider = loggerProvider, - detektConfig = extension.detekt, - ) -} - -private fun Project.composeDetektConfig( - versionCatalogName: Provider, - extension: AppQualityFoundationExtension, - loggerProvider: Provider, -): PlatformDetektConfig { - return extension.detekt.compose.also { - val composeRulesLib = "detekt-compose-rules" - it.rulesLibraries.convention( - libs(versionCatalogName.get()) - .findLibrary(composeRulesLib.replace("-", ".")) - .orElseThrow { GradleException(noDetektRulesDependencyReferenceInLibsMessage(composeRulesLib)) } - .map { lib -> listOf(lib) }, - ) - val detektProjectFile = - layout.projectDirectory - .file("detekt-compose-config.yml") - val detektFallbackFile = - layout.buildDirectory - .file("detekt/compose-config.yml") - .get() - val detektDefaultFile = "detekt/default.compose-config.yml" - val detektConfigProvider = - resolveFile( - detektProjectFile, - detektFallbackFile, - detektDefaultFile, - loggerProvider, - ) - it.projectConfig.convention(detektConfigProvider) - } -} - -private fun Project.androidDetektConfig( - extension: AppQualityFoundationExtension, - loggerProvider: Provider, -): PlatformDetektConfig { - return extension.detekt.android.also { - val detektProjectFile = - layout.projectDirectory - .file("detekt-android-config.yml") - val detektFallbackFile = - layout.buildDirectory - .file("detekt/android-config.yml") - .get() - val detektDefaultFile = "detekt/default.android-config.yml" - val detektConfigProvider = - resolveFile( - detektProjectFile, - detektFallbackFile, - detektDefaultFile, - loggerProvider, - ) - it.projectConfig.convention(detektConfigProvider) - } -} - -private fun Project.kotlinDetektConfig( - versionCatalogName: Provider, - extension: AppQualityFoundationExtension, - loggerProvider: Provider, -): PlatformDetektConfig { - return extension.detekt.kotlin.also { - it.rulesPluginJar.convention { - project.rootProject.file("libs/detekt-rules-1.4.0.jar") - } - - val formatingLib = "detekt-formatting" - it.rulesLibraries.convention( - libs(versionCatalogName.get()) - .findLibrary(formatingLib.replace("-", ".")) - .orElseThrow { GradleException(noDetektRulesDependencyReferenceInLibsMessage(formatingLib)) } - .map { lib -> listOf(lib) }, - ) - val detektProjectFile = - layout.projectDirectory - .file("detekt-kotlin-config.yml") - val detektFallbackFile = - layout.buildDirectory - .file("detekt/kotlin-config.yml") - .get() - val detektDefaultFile = "detekt/default.kotlin-config.yml" - val detektConfigProvider = - resolveFile( - detektProjectFile, - detektFallbackFile, - detektDefaultFile, - loggerProvider, - ) - it.projectConfig.convention(detektConfigProvider) - } -} - -private fun Project.configureDetekt( - loggerProvider: Provider, - verboseLogging: Provider, - config: PlatformDetektConfig, - detektConfig: DetektConfig, -) { - val logger = loggerProvider.orNull - val detektRulesPluginJars = config.rulesPluginJar.orNull - val detektProjectConfigPath = config.projectConfig.orNull - val detektLibraries = config.rulesLibraries.get() - - val detektPlugins = configurations.getAt("detektPlugins") - - detektRulesPluginJars?.asFile?.let { jar -> - if (!detektPlugins.dependencies.any { it is FileCollectionDependency && it.files.contains(jar) }) { - logger?.info("Adding detekt plugin jar $jar") - dependencies.add(detektPlugins.name, files(jar)) - } else { - logger?.info("SKIP adding detekt plugin jar $jar") - } - } - - detektLibraries.forEach { lib -> - if (!detektPlugins.dependencies.any { - it is ExternalModuleDependency && it.group == lib.module.group && it.name == lib.module.name - } - ) { - logger?.info("Adding detekt plugin library $lib") - dependencies.add(detektPlugins.name, lib) - } else { - logger?.info("SKIP adding detekt plugin library $lib") - } - } - - extensions.configure(DetektExtension::class.java) { detektExtension -> - detektProjectConfigPath?.asFile?.let { configFile -> - if (!detektExtension.config.contains(configFile)) { - logger?.info("Adding detekt config $configFile") - val mergedConfig = (detektExtension.config.files + configFile).distinct() - logger?.info("Adding merged config files $mergedConfig") - detektExtension.config.from(mergedConfig) - } else { - logger?.info("SKIP adding detekt config $configFile") - } - } - - detektExtension.debug = verboseLogging.get() - val ignoredBuildTypes = detektConfig.ignoredBuildTypes.get() - val mergedIgnoredBuiltTypes = (detektExtension.ignoredBuildTypes + ignoredBuildTypes).distinct() - detektExtension.ignoredBuildTypes = - mergedIgnoredBuiltTypes.also { - logger?.info("Detekt ignoredBuildTypes = $it") - } + val aggregateTasks = + project.configureAggregateTasks(extension, gitHooksSetup, ktlintTasks, loggerServiceProvider) + project.configureAndroidLint(extension, aggregateTasks) } } - -private fun Project.configureDetektTasks( - verboseLogging: Provider, - jvmTarget: Provider, - loggerProvider: Provider, - detektConfig: DetektConfig, -) { - val jvmTargetProvider = - ( - tasks.withType(KotlinJvmCompile::class.java) - .firstOrNull() - ?.compilerOptions - ?.jvmTarget - ?.convention(jvmTarget) - ?: jvmTarget - ) - .map { it.target } - - val typeResolution = detektConfig.typeResolution.get() - val additionallyExcludedPaths = detektConfig.additionallyExcludedPaths.get() - val additionalSourcePaths = detektConfig.additionalSourcePaths.get() - - tasks.withType(DetektCreateBaselineTask::class.java).configureEach { task -> - task.usesService(loggerProvider) - task.jvmTarget = jvmTargetProvider.get() - task.debug.set(verboseLogging) - } - - tasks.withType(Detekt::class.java).configureEach { task -> - task.usesService(loggerProvider) - task.debug = verboseLogging.get() - - val compileTask = - tasks.withType(KotlinJvmCompile::class.java) - .find { it.name.contains(task.name.removePrefix("detekt"), ignoreCase = true) } - - if (compileTask != null && typeResolution) { - task.classpath.setFrom(compileTask.libraries) - } - - task.jvmTarget = jvmTargetProvider.get() - - task.exclude { fileTreeElement -> - val sep = File.separator - val absolutePath = fileTreeElement.file.absolutePath - - absolutePath.contains("${sep}generated$sep") || - absolutePath.contains("${sep}build$sep") || - additionallyExcludedPaths.any { absolutePath.contains("${sep}${it}$sep") } - } - - val sourcesPaths: List = - listOf( - "src/main/kotlin", - "src/test/kotlin", - "src/commonMain/kotlin", - "src/commonTest/kotlin", - "src/desktopMain/kotlin", - "src/desktopTest/kotlin", - "src/iosMain/kotlin", - "src/iosTest/kotlin", - "src/androidMain/kotlin", - "src/androidTest/kotlin", - ) + additionalSourcePaths - - task.source(files(sourcesPaths)) - - task.reports { - it.xml.required.set(false) - it.html.required.set(false) - it.txt.required.set(false) - it.sarif.required.set(false) - } - } -} - -private data class KtlintTasks( - val check: TaskProvider, - val format: TaskProvider, -) diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigFileResolution.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigFileResolution.kt new file mode 100644 index 0000000..ee5e15b --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigFileResolution.kt @@ -0,0 +1,144 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import ru.kode.android.app.quality.plugin.foundation.extension.AppQualityFoundationExtension +import ru.kode.android.app.quality.plugin.foundation.task.GenerateDefaultConfigFileTask +import ru.kode.android.app.quality.plugin.foundation.task.GenerateDefaultRulesJarTask +import ru.kode.android.app.quality.plugin.foundation.utils.catalogLibraryOrDefault +import java.util.Base64 +import java.util.Properties + +internal const val KODE_ANDROID_RULES_JAR_NAME = "kode-android-rules-1.4.0.jar" + +private val defaultToolVersions: Properties by lazy { readBundledProperties("default-tool-versions.properties") } + +internal fun defaultToolVersion(alias: String): String = + defaultToolVersions.getProperty(alias) + ?: throw GradleException( + "Default tool version for '$alias' not found in bundled default-tool-versions.properties", + ) + +internal data class DefaultConfigFiles( + val detektKotlin: Provider, + val detektAndroid: Provider, + val detektCompose: Provider, + val detektAndroidRulesJar: Provider, + val editorconfig: Provider, +) + +/** + * Sets lazy conventions and seeds the dependency slots' defaults right after the extension is + * created. Catalog lookups are deferred until first use, and defaults are queried only while + * the slot's `useDefaults` is true. Each slot prefers a matching alias from the consumer's own + * `libs` catalog when present, falling back to a coordinate baked into the plugin otherwise — + * so the plugin works with zero catalog setup, while an existing project's pinned version (if + * declared) keeps winning unchanged. + */ +internal fun Project.configureConventions( + extension: AppQualityFoundationExtension, + defaultConfigs: DefaultConfigFiles, +) { + extension.gitHooks.convention(rootProject.layout.projectDirectory.file(".githooks")) + extension.ktlint.cli.defaults.add( + catalogLibraryOrDefault("ktlint-cli", defaultToolVersion("ktlint-cli")), + ) + extension.detekt.kotlin.rules.defaults.add( + catalogLibraryOrDefault("detekt-formatting", defaultToolVersion("detekt-formatting")), + ) + extension.detekt.compose.rules.defaults.add( + catalogLibraryOrDefault("detekt-compose-rules", defaultToolVersion("detekt-compose-rules")), + ) + extension.detekt.android.rules.defaultFiles.add(files(defaultConfigs.detektAndroidRulesJar)) +} + +/** + * Registers root-level tasks that materialize the bundled default configs into the build + * directory. Consumers depend on the outputs through providers, so the tasks run only when + * a default is actually needed and nothing is written at configuration time. + */ +internal fun Project.registerDefaultConfigTasks(): DefaultConfigFiles { + fun register( + taskName: String, + resourcePath: String, + outputPath: String, + ): Provider { + val task = + tasks.register(taskName, GenerateDefaultConfigFileTask::class.java) { t -> + t.resourceContent.set(providers.provider { readBundledResource(resourcePath) }) + t.outputFile.set(layout.buildDirectory.file(outputPath)) + } + return task.flatMap { it.outputFile } + } + + fun registerJar( + taskName: String, + resourcePath: String, + outputPath: String, + ): Provider { + val task = + tasks.register(taskName, GenerateDefaultRulesJarTask::class.java) { t -> + t.resourceContentBase64.set( + providers.provider { + Base64.getEncoder().encodeToString(readBundledResourceBytes(resourcePath)) + }, + ) + t.outputFile.set(layout.buildDirectory.file(outputPath)) + } + return task.flatMap { it.outputFile } + } + return DefaultConfigFiles( + detektKotlin = + register( + "generateDefaultDetektKotlinConfig", + "detekt/default.kotlin-config.yml", + "app-quality/detekt/kotlin-config.yml", + ), + detektAndroid = + register( + "generateDefaultDetektAndroidConfig", + "detekt/default.android-config.yml", + "app-quality/detekt/android-config.yml", + ), + detektCompose = + register( + "generateDefaultDetektComposeConfig", + "detekt/default.compose-config.yml", + "app-quality/detekt/compose-config.yml", + ), + detektAndroidRulesJar = + registerJar( + "generateDefaultDetektAndroidRulesJar", + "detekt/rules/$KODE_ANDROID_RULES_JAR_NAME", + "app-quality/detekt/rules/$KODE_ANDROID_RULES_JAR_NAME", + ), + editorconfig = + register( + "generateDefaultKtlintEditorconfig", + "ktlint/default.editorconfig", + "app-quality/ktlint/.editorconfig", + ), + ) +} + +private fun readBundledResource(path: String): String { + return PluginResources::class.java.getResourceAsStream(path) + ?.bufferedReader() + ?.use { it.readText() } + ?: throw GradleException("Default file ($path) not found in plugin resources") +} + +private fun readBundledProperties(path: String): Properties { + val stream = + PluginResources::class.java.getResourceAsStream(path) + ?: throw GradleException("Default file ($path) not found in plugin resources") + return stream.use { Properties().apply { load(it) } } +} + +private fun readBundledResourceBytes(path: String): ByteArray { + return PluginResources::class.java.getResourceAsStream(path) + ?.use { it.readBytes() } + ?: throw GradleException("Default file ($path) not found in plugin resources") +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektWiring.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektWiring.kt new file mode 100644 index 0000000..da10048 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektWiring.kt @@ -0,0 +1,279 @@ +package ru.kode.android.app.quality.plugin.foundation + +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import io.gitlab.arturbosch.detekt.DetektPlugin +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import ru.kode.android.app.quality.plugin.foundation.config.PlatformDetektConfig +import ru.kode.android.app.quality.plugin.foundation.config.hasNoUserAdditionsProvider +import ru.kode.android.app.quality.plugin.foundation.extension.AppQualityFoundationExtension +import ru.kode.android.app.quality.plugin.foundation.messages.missingDependencyFileMessage +import ru.kode.android.app.quality.plugin.foundation.messages.missingKodeRuleSetDependencyMessage +import ru.kode.android.app.quality.plugin.foundation.utils.activatesKodeRuleSet +import ru.kode.android.app.quality.plugin.foundation.utils.resolveConfigFile +import ru.kode.android.app.quality.plugin.foundation.utils.wireDependencies +import ru.kode.android.app.quality.plugin.foundation.validate.validateSubprojectAgpVersion +import ru.kode.android.build.publish.plugin.core.logger.LoggerService + +internal val DEFAULT_DETEKT_INCLUDE_PATTERNS = + listOf( + "src/main/kotlin/**", + "src/test/kotlin/**", + "src/commonMain/kotlin/**", + "src/commonTest/kotlin/**", + "src/jvmMain/kotlin/**", + "src/jvmTest/kotlin/**", + "src/desktopMain/kotlin/**", + "src/desktopTest/kotlin/**", + "src/iosMain/kotlin/**", + "src/iosTest/kotlin/**", + "src/androidMain/kotlin/**", + "src/androidTest/kotlin/**", + ) + +internal val DEFAULT_DETEKT_EXCLUDE_PATTERNS = listOf("**/generated/**", "**/build/**") + +internal fun Project.configureSubprojectsDetekt( + extension: AppQualityFoundationExtension, + loggerProvider: Provider, + defaults: DefaultConfigFiles, +) { + subprojects { subproject -> + subproject.configureProjectDetekt(extension, loggerProvider, defaults) + } +} + +internal enum class DetektPlatform { KOTLIN, ANDROID, COMPOSE } + +/** + * Configures detekt lazily per module: the detekt plugin is applied only when a matching + * Kotlin/Android/Compose plugin is applied, and each platform config is merged exactly once + * even when several trigger plugins are present. + */ +private fun Project.configureProjectDetekt( + extension: AppQualityFoundationExtension, + loggerProvider: Provider, + defaults: DefaultConfigFiles, +) { + val configuredPlatforms = mutableSetOf() + + fun configurePlatformOnce( + platform: DetektPlatform, + platformConfig: PlatformDetektConfig, + configFileName: String, + bundledDefault: Provider, + ) { + if (!configuredPlatforms.add(platform)) return + pluginManager.apply(DetektPlugin::class.java) + if (configuredPlatforms.size == 1) { + configureDetektTasks(extension, loggerProvider) + } + val configFile = + resolveConfigFile( + override = platformConfig.projectConfig, + candidate = layout.projectDirectory.file(configFileName), + bundledDefault = bundledDefault, + ) + configureDetekt(extension, platformConfig, configFile, platform.name.lowercase(), configuredPlatforms) + } + + listOf( + "org.jetbrains.kotlin.jvm", + "org.jetbrains.kotlin.multiplatform", + "org.jetbrains.kotlin.android", + "com.android.application", + "com.android.library", + ).forEach { pluginId -> + pluginManager.withPlugin(pluginId) { + configurePlatformOnce( + DetektPlatform.KOTLIN, + extension.detekt.kotlin, + "detekt-kotlin-config.yml", + defaults.detektKotlin, + ) + } + } + + listOf("org.jetbrains.kotlin.android", "com.android.application", "com.android.library") + .forEach { pluginId -> + pluginManager.withPlugin(pluginId) { + // The foundation plugin itself is usually applied at the root only, so + // stopExecutionIfNotSupported never sees a subproject applying AGP directly. + validateSubprojectAgpVersion() + configurePlatformOnce( + DetektPlatform.ANDROID, + extension.detekt.android, + "detekt-android-config.yml", + defaults.detektAndroid, + ) + } + } + + listOf("org.jetbrains.compose", "org.jetbrains.kotlin.plugin.compose") + .forEach { pluginId -> + pluginManager.withPlugin(pluginId) { + configurePlatformOnce( + DetektPlatform.COMPOSE, + extension.detekt.compose, + "detekt-compose-config.yml", + defaults.detektCompose, + ) + } + } +} + +private fun Project.configureDetekt( + extension: AppQualityFoundationExtension, + platformConfig: PlatformDetektConfig, + configFile: Provider, + platformName: String, + configuredPlatforms: Set, +) { + configurations.named("detektPlugins").configure { detektPlugins -> + wireDependencies(detektPlugins, platformConfig.rules) { file -> + missingDependencyFileMessage(file, "detekt.$platformName.rules") + } + } + + // Composed entirely from Provider combinators (no closure captures a live domain object + // like `extension` or an `ExternalDependencyConfig` directly) — Gradle's config-cache + // support for Provider graphs is structural, but naive closures capturing a live object + // get naively Java-serialized instead, walking its ENTIRE reachable graph. That previously + // broke config-cache for EVERY platform's detekt task (not just android's), because + // `extension.detekt.android.rules.defaultFiles` (holding the bundled kode jar's + // `FileCollectionDependency`, unserializable by Gradle) was reachable from any closure + // that captured `extension` as a whole, even one that never actually reads that field. + val noKodeJarWired = noKodeRuleSetJarWiredAnywhereProvider(extension, configuredPlatforms) + val validatedConfigFile = + configFile.map { file -> + val configuredFile = file.asFile + if (configuredFile.exists() && activatesKodeRuleSet(configuredFile) && noKodeJarWired.get()) { + throw GradleException(missingKodeRuleSetDependencyMessage(platformName, configuredFile)) + } + file + } + + extensions.configure(DetektExtension::class.java) { detektExtension -> + detektExtension.config.from(validatedConfigFile) + // `extension` lives on the root project, and this callback fires while a SUBPROJECT's + // plugins are being applied — reading the extension's Property values here directly + // would depend on the root project's own build script having already run its + // `appQualityFoundation { }` block, which Gradle does not guarantee relative to + // subproject evaluation. Read immediately if the root is already evaluated (the common + // case: root config runs before subprojects); otherwise defer to its `afterEvaluate` — + // it cannot be registered unconditionally, since Gradle forbids `afterEvaluate` once a + // project has finished evaluating. + val applyExtensionValues = { + detektExtension.debug = extension.verboseLogging.get() + detektExtension.ignoredBuildTypes = + (detektExtension.ignoredBuildTypes + extension.detekt.ignoredBuildTypes.get()).distinct() + extension.detekt.baseline.orNull?.let { baseline -> + detektExtension.baseline = baseline.asFile + } + } + if (rootProject.state.executed) { + applyExtensionValues() + } else { + rootProject.afterEvaluate { applyExtensionValues() } + } + } +} + +/** + * `detektPlugins` is ONE configuration shared by every platform in the project, so a jar wired + * into any platform's `rules` slot is on the classpath for all of them — this must check all 3 + * slots, not just the platform being configured, or it false-positives whenever the jar was + * wired through a different platform (e.g. only `detekt.kotlin.rules`). `detekt.android.rules` + * now has a real bundled default (the kode jar itself), so it satisfies this check whenever + * `useDefaults` is on — but ONLY if the android platform is actually configured for this + * project: `useDefaults` on that slot defaults to `true` even for a project with no Android + * module at all, where the default never reaches `detektPlugins` because `configureDetekt` + * never runs for `ANDROID`. [configuredPlatforms] is read lazily (same mutable set the caller + * populates during `pluginManager.withPlugin` callbacks) so by the time this actually + * evaluates — task execution, after the whole project's configuration phase is done — it + * reflects every platform this project ended up configuring, regardless of callback order. + * + * Built entirely from `Provider.map`/`.zip` — never reads `extension`/`ExternalDependencyConfig` + * directly inside a closure passed to a consuming Provider (see the config-cache note at the + * call site for why that matters). + */ +private fun noKodeRuleSetJarWiredAnywhereProvider( + extension: AppQualityFoundationExtension, + configuredPlatforms: Set, +): Provider { + val androidDefaultInactive = + extension.detekt.android.rules.useDefaults.map { use -> + !(DetektPlatform.ANDROID in configuredPlatforms && use) + } + val kotlinEmpty = extension.detekt.kotlin.rules.hasNoUserAdditionsProvider() + val androidEmpty = extension.detekt.android.rules.hasNoUserAdditionsProvider() + val composeEmpty = extension.detekt.compose.rules.hasNoUserAdditionsProvider() + return androidDefaultInactive + .zip(kotlinEmpty) { defaultInactive, kEmpty -> defaultInactive && kEmpty } + .zip(androidEmpty) { partial, aEmpty -> partial && aEmpty } + .zip(composeEmpty) { partial, cEmpty -> partial && cEmpty } +} + +/** + * Tunes detekt tasks. Runs inside `configureEach`, which fires at task-graph time — after the + * consumer's extension block — so all extension reads here observe the configured values. + */ +private fun Project.configureDetektTasks( + extension: AppQualityFoundationExtension, + loggerProvider: Provider, +) { + val detektConfig = extension.detekt + + tasks.withType(DetektCreateBaselineTask::class.java).configureEach { task -> + task.usesService(loggerProvider) + task.jvmTarget = extension.jvmTarget.get().target + task.debug.set(extension.verboseLogging) + } + + tasks.withType(Detekt::class.java).configureEach { task -> + task.usesService(loggerProvider) + task.debug = extension.verboseLogging.get() + task.jvmTarget = extension.jvmTarget.get().target + + if (detektConfig.typeResolution.get()) { + val variantName = task.name.removePrefix("detekt") + val compileTask = + tasks.withType(KotlinJvmCompile::class.java) + .find { it.name.contains(variantName, ignoreCase = true) } + if (compileTask != null) { + task.classpath.setFrom(compileTask.libraries) + } + } + + val includePatterns = + if (detektConfig.sources.useDefaults.get()) { + DEFAULT_DETEKT_INCLUDE_PATTERNS + detektConfig.sources.include.get() + } else { + detektConfig.sources.include.get() + } + val excludePatterns = DEFAULT_DETEKT_EXCLUDE_PATTERNS + detektConfig.sources.exclude.get() + task.source = + fileTree(layout.projectDirectory) { tree -> + if (includePatterns.isEmpty()) { + // Gradle's PatternFilterable treats an empty include list as "no + // restriction" (matches everything), so exclude everything instead. + tree.exclude("**") + } else { + tree.include(includePatterns) + tree.exclude(excludePatterns) + } + } + + task.reports { + it.xml.required.set(detektConfig.xmlReportEnabled) + it.html.required.set(false) + it.txt.required.set(false) + it.sarif.required.set(detektConfig.sarifReportEnabled) + } + } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintWiring.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintWiring.kt new file mode 100644 index 0000000..b4bf9e2 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintWiring.kt @@ -0,0 +1,186 @@ +@file:Suppress("MatchingDeclarationName") // file groups the ktlint wiring fun with its small result type + +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.type.ArtifactTypeDefinition +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.LibraryElements +import org.gradle.api.attributes.Usage +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskProvider +import org.gradle.process.CommandLineArgumentProvider +import ru.kode.android.app.quality.plugin.foundation.config.KtlintConfig +import ru.kode.android.app.quality.plugin.foundation.messages.missingDependencyFileMessage +import ru.kode.android.app.quality.plugin.foundation.messages.noEditorConfigFileMessage +import ru.kode.android.app.quality.plugin.foundation.utils.ignoredSourcePatterns +import ru.kode.android.app.quality.plugin.foundation.utils.kotlinSourcePatterns +import ru.kode.android.app.quality.plugin.foundation.utils.resolveConfigFile +import ru.kode.android.app.quality.plugin.foundation.utils.wireDependencies +import ru.kode.android.build.publish.plugin.core.logger.LoggerService +import java.util.concurrent.Callable + +internal data class KtlintTasks( + val check: TaskProvider, + val format: TaskProvider, +) + +@Suppress("LongMethod") +internal fun Project.configureKtlint( + config: KtlintConfig, + loggerServiceProvider: Provider, + defaultEditorConfig: Provider, +): KtlintTasks { + val ktlintCli = configurations.create("ktlintCli") + wireDependencies(ktlintCli, config.cli) { file -> + missingDependencyFileMessage(file, "ktlint.cli") + } + + ktlintCli.attributes { attrs -> + attrs.attribute( + ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, + ArtifactTypeDefinition.JAR_TYPE, + ) + attrs.attribute( + Usage.USAGE_ATTRIBUTE, + objects.named(Usage::class.java, Usage.JAVA_RUNTIME), + ) + attrs.attribute( + Bundling.BUNDLING_ATTRIBUTE, + objects.named(Bundling::class.java, Bundling.SHADOWED), + ) + attrs.attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, + objects.named(LibraryElements::class.java, LibraryElements.JAR), + ) + attrs.attribute( + Category.CATEGORY_ATTRIBUTE, + objects.named(Category::class.java, Category.LIBRARY), + ) + } + + val validatedProjectConfig = + config.projectConfig.map { file -> + if (!file.asFile.exists()) { + throw GradleException(noEditorConfigFileMessage(file.asFile)) + } + file + } + val editorConfig = + resolveConfigFile( + override = validatedProjectConfig, + candidate = rootProject.layout.projectDirectory.file(".editorconfig"), + bundledDefault = defaultEditorConfig, + ) + val ignoredPatterns = ignoredSourcePatterns(config.sources) + val sourcePatterns = kotlinSourcePatterns(config.sources) + + fun registerKtlintTask( + name: String, + taskGroup: String, + taskDescription: String, + format: Boolean, + ): TaskProvider { + return tasks.register(name, JavaExec::class.java) { task -> + task.usesService(loggerServiceProvider) + + task.group = taskGroup + task.description = taskDescription + + task.classpath = ktlintCli + task.mainClass.set("com.pinterest.ktlint.Main") + + task.jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + ) + + task.inputs.file(editorConfig) + .withPropertyName("editorConfig") + .withPathSensitivity(PathSensitivity.NONE) + .optional() + task.inputs.property("ignoredSourcePatterns", ignoredPatterns) + task.inputs.property("sourcePatterns", sourcePatterns) + + task.doFirst { + val editorConfigFile = editorConfig.get().asFile + loggerServiceProvider.get().info("Use editor config for $name = ${editorConfigFile.absolutePath}") + } + + task.argumentProviders.add( + CommandLineArgumentProvider { + val editorConfigPath = editorConfig.get().asFile.absolutePath.replace('\\', '/') + val patterns = sourcePatterns.get() + buildList { + if (format) add("-F") + add("--editorconfig=$editorConfigPath") + add("--relative") + addAll(ignoredPatterns.get()) + if (patterns.isEmpty()) { + // No include patterns configured: ktlint-cli falls back to its own + // built-in default globs when given zero positional patterns, so + // explicitly exclude everything to keep "nothing configured" meaning + // "nothing checked". + add("!**") + } else { + addAll(patterns) + } + } + }, + ) + } + } + + fun trackKotlinSourcesAsInputs(task: JavaExec) { + task.inputs.files( + Callable { + fileTree(layout.projectDirectory) { tree -> + tree.include(sourcePatterns.get()) + tree.exclude(ignoredPatterns.get().map { it.removePrefix("!") }) + } + }, + ) + .withPropertyName("kotlinSources") + .withPathSensitivity(PathSensitivity.RELATIVE) + } + + val ktlintCheck = + registerKtlintTask( + name = "ktlintCheck", + taskGroup = "verification", + taskDescription = "Run ktlint check on all Android modules", + format = false, + ) + val ktlintFormat = + registerKtlintTask( + name = "ktlintFormat", + taskGroup = "formatting", + taskDescription = "Run ktlint format on all Android modules", + format = true, + ) + ktlintCheck.configure { task -> + trackKotlinSourcesAsInputs(task) + val marker = layout.buildDirectory.file("app-quality/ktlint/check-marker.txt") + task.outputs.file(marker).withPropertyName("checkMarker") + task.doLast { + marker.get().asFile.writeText("ktlint check passed") + } + } + ktlintFormat.configure { task -> + trackKotlinSourcesAsInputs(task) + val marker = layout.buildDirectory.file("app-quality/ktlint/format-marker.txt") + task.outputs.file(marker).withPropertyName("formatMarker") + task.doLast { + marker.get().asFile.writeText("ktlint format applied") + } + } + return KtlintTasks( + check = ktlintCheck, + format = ktlintFormat, + ) +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/AndroidLintConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/AndroidLintConfig.kt new file mode 100644 index 0000000..c462158 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/AndroidLintConfig.kt @@ -0,0 +1,18 @@ +package ru.kode.android.app.quality.plugin.foundation.config + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * Opt-in wiring for the Android Gradle Plugin's own `lint` task into the aggregate + * `pipelineCheck`/`prePushCheck` tasks. Off by default: lint is slow and most consumers + * already run it separately in CI. + */ +abstract class AndroidLintConfig + @Inject + constructor(objectFactory: ObjectFactory) { + val enabled: Property = + objectFactory.property(Boolean::class.java) + .convention(false) + } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/DetektConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/DetektConfig.kt index a32cfd1..f67f282 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/DetektConfig.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/DetektConfig.kt @@ -1,9 +1,14 @@ package ru.kode.android.app.quality.plugin.foundation.config +import groovy.lang.Closure +import groovy.lang.DelegatesTo +import org.gradle.api.Action +import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Nested +import ru.kode.android.build.publish.plugin.core.util.configureGroovy import javax.inject.Inject abstract class DetektConfig @@ -13,27 +18,87 @@ abstract class DetektConfig val kotlin: PlatformDetektConfig = objectFactory.newInstance(PlatformDetektConfig::class.java) + fun kotlin(action: Action) { + action.execute(kotlin) + } + + fun kotlin( + @DelegatesTo(value = PlatformDetektConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, kotlin) + } + @get:Nested val android: PlatformDetektConfig = objectFactory.newInstance(PlatformDetektConfig::class.java) + fun android(action: Action) { + action.execute(android) + } + + fun android( + @DelegatesTo(value = PlatformDetektConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, android) + } + @get:Nested val compose: PlatformDetektConfig = objectFactory.newInstance(PlatformDetektConfig::class.java) + fun compose(action: Action) { + action.execute(compose) + } + + fun compose( + @DelegatesTo(value = PlatformDetektConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, compose) + } + val ignoredBuildTypes: ListProperty = objectFactory.listProperty(String::class.java) .convention(listOf("release", "internal", "external", "demo")) - val additionallyExcludedPaths: ListProperty = - objectFactory.listProperty(String::class.java) - .convention(emptyList()) + /** + * The detekt source-path slot: `include`/`exclude` globs (bare, no `!` prefix), + * gated by `useDefaults`. Default = the plugin's bundled per-platform source paths. + */ + val sources: SourcePatternsConfig = + objectFactory.newInstance(SourcePatternsConfig::class.java) - val additionalSourcePaths: ListProperty = - objectFactory.listProperty(String::class.java) - .convention(emptyList()) + fun sources(action: Action) { + action.execute(sources) + } + + fun sources( + @DelegatesTo(value = SourcePatternsConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, sources) + } val typeResolution: Property = objectFactory.property(Boolean::class.java) .convention(false) + + /** + * Optional detekt baseline file: findings already present in it are suppressed. + * Unset by default (no baseline) — set to adopt detekt incrementally on a legacy + * module, e.g. `baseline.set(layout.projectDirectory.file("detekt-baseline.xml"))`. + */ + val baseline: RegularFileProperty = objectFactory.fileProperty() + + /** Emit detekt's XML report per task. Default `false`. */ + val xmlReportEnabled: Property = + objectFactory.property(Boolean::class.java) + .convention(false) + + /** Emit detekt's SARIF report per task (e.g. for GitHub code scanning). Default `false`. */ + val sarifReportEnabled: Property = + objectFactory.property(Boolean::class.java) + .convention(false) } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/ExternalDependencyConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/ExternalDependencyConfig.kt new file mode 100644 index 0000000..420bc0f --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/ExternalDependencyConfig.kt @@ -0,0 +1,66 @@ +package ru.kode.android.app.quality.plugin.foundation.config + +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.dsl.DependencyCollector +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import javax.inject.Inject + +/** + * A dependency slot configurable from any source: version-catalog accessors, string + * coordinates, jar files, or Dependency objects. [from] is Gradle's [DependencyCollector], + * so all of `from("group:name:version")`, `from(libs.alias)` and `from(files("x.jar"))` + * work inside the configuration block. + * + * The plugin's default for the slot is independent of user additions and controlled only + * by [useDefaults] — additions always stack on top of the default unless it is disabled. + */ +abstract class ExternalDependencyConfig + @Inject + constructor(objectFactory: ObjectFactory) { + /** + * User-added dependency sources (add-only; all source kinds). + * Constraints can be added via `from.addConstraint(...)`. + */ + abstract val from: DependencyCollector + + /** Include the plugin's defaults for this slot. Default `true`. */ + val useDefaults: Property = + objectFactory.property(Boolean::class.java) + .convention(true) + + // MUST stay a concrete internal val (NOT an abstract managed property): Kotlin mangles + // internal member JVM names, which breaks the class generator's property recognition. + // Catalog/coordinate-based defaults only (ExternalModuleDependency-shaped values) — + // file-based defaults go through [defaultFiles] instead, see its doc for why. + internal val defaults: ListProperty = + objectFactory.listProperty(Dependency::class.java) + + /** + * Plugin-seeded file-based defaults (e.g. a bundled rules jar materialized from a + * plugin resource) — kept as a SEPARATE [DependencyCollector] from [defaults], not a + * `ListProperty` entry: a raw [org.gradle.api.artifacts.FileCollectionDependency] + * stored in a plain `ListProperty` fails Gradle's configuration-cache serialization + * ("cannot serialize DefaultFileCollectionDependency"), empirically confirmed — whereas + * `DependencyCollector.add(FileCollection)` (the real method backing [from]'s + * `from(files(...))` DSL sugar) has native, working config-cache support. Not part of + * the public DSL surface; the plugin seeds it via `.add(...)` directly, never the user. + */ + abstract val defaultFiles: DependencyCollector + } + +/** + * Whether the user has added anything to this slot explicitly, as a lazy [Provider] — not an + * eager `Boolean`, so callers can compose it into a Provider chain without capturing this + * [ExternalDependencyConfig] instance directly inside a closure (see the config-cache note on + * `noKodeRuleSetJarWiredAnywhereProvider` in `AppQualityFoundationPlugin.kt` for why that + * distinction matters). Deliberately ignores the slot's baked-in plugin default + * (`detekt-formatting`, `detekt-compose-rules`): those are known never to satisfy a custom rule + * set like `kode`, so counting them here would silently defeat a "did you forget to add the + * rules jar" check for any slot that has one. + */ +internal fun ExternalDependencyConfig.hasNoUserAdditionsProvider(): Provider { + return from.dependencies.map { it.isEmpty() } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/KtlintConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/KtlintConfig.kt index 6c3d9eb..782118b 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/KtlintConfig.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/KtlintConfig.kt @@ -1,10 +1,11 @@ package ru.kode.android.app.quality.plugin.foundation.config -import org.gradle.api.artifacts.MinimalExternalModuleDependency +import groovy.lang.Closure +import groovy.lang.DelegatesTo +import org.gradle.api.Action import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property +import ru.kode.android.build.publish.plugin.core.util.configureGroovy import javax.inject.Inject abstract class KtlintConfig @@ -12,14 +13,40 @@ abstract class KtlintConfig constructor(objectFactory: ObjectFactory) { val projectConfig: RegularFileProperty = objectFactory.fileProperty() - val cliLibrary: Property = - objectFactory.property(MinimalExternalModuleDependency::class.java) + /** + * The ktlint CLI classpath slot. Default = the `ktlint-cli` alias of the `libs` + * version catalog; configure from any source: + * `cli { from("com.pinterest.ktlint:ktlint-cli:1.8.0"); useDefaults.set(false) }`. + */ + val cli: ExternalDependencyConfig = + objectFactory.newInstance(ExternalDependencyConfig::class.java) - val additionalSourcePatterns: ListProperty = - objectFactory.listProperty(String::class.java) - .convention(emptyList()) + fun cli(action: Action) { + action.execute(cli) + } - val additionalIgnoredSourcePatterns: ListProperty = - objectFactory.listProperty(String::class.java) - .convention(emptyList()) + fun cli( + @DelegatesTo(value = ExternalDependencyConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, cli) + } + + /** + * The Kotlin source-pattern slot: `include`/`exclude` globs (bare, no `!` prefix), + * gated by `useDefaults`. Default = the plugin's bundled Kotlin source/ignore globs. + */ + val sources: SourcePatternsConfig = + objectFactory.newInstance(SourcePatternsConfig::class.java) + + fun sources(action: Action) { + action.execute(sources) + } + + fun sources( + @DelegatesTo(value = SourcePatternsConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, sources) + } } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/PlatformDetektConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/PlatformDetektConfig.kt index d8524cd..23000e4 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/PlatformDetektConfig.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/PlatformDetektConfig.kt @@ -1,9 +1,11 @@ package ru.kode.android.app.quality.plugin.foundation.config -import org.gradle.api.artifacts.MinimalExternalModuleDependency +import groovy.lang.Closure +import groovy.lang.DelegatesTo +import org.gradle.api.Action import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty +import ru.kode.android.build.publish.plugin.core.util.configureGroovy import javax.inject.Inject abstract class PlatformDetektConfig @@ -11,8 +13,24 @@ abstract class PlatformDetektConfig constructor(objectFactory: ObjectFactory) { val projectConfig: RegularFileProperty = objectFactory.fileProperty() - val rulesLibraries: ListProperty = - objectFactory.listProperty(MinimalExternalModuleDependency::class.java) + /** + * This platform's detekt rule-set slot, added to `detektPlugins` of matching modules. + * Defaults per platform (`detekt-formatting` for kotlin, `detekt-compose-rules` for + * compose); configure from any source: + * `rules { from(files("libs/my-rules.jar")) }` — additions stack ON TOP of the + * default unless `useDefaults.set(false)`. + */ + val rules: ExternalDependencyConfig = + objectFactory.newInstance(ExternalDependencyConfig::class.java) - val rulesPluginJar: RegularFileProperty = objectFactory.fileProperty() + fun rules(action: Action) { + action.execute(rules) + } + + fun rules( + @DelegatesTo(value = ExternalDependencyConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, rules) + } } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/SourcePatternsConfig.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/SourcePatternsConfig.kt new file mode 100644 index 0000000..d93874f --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/config/SourcePatternsConfig.kt @@ -0,0 +1,40 @@ +package ru.kode.android.app.quality.plugin.foundation.config + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * A source-pattern slot: add-only `include`/`exclude` glob lists, gated by [useDefaults]. + * Patterns are bare (no `!` prefix) — consumers that need the ktlint-CLI ignore syntax add + * the prefix themselves when building CLI args. + */ +abstract class SourcePatternsConfig + @Inject + constructor(objectFactory: ObjectFactory) { + /** User-added include patterns (add-only). */ + val include: ListProperty = + objectFactory.listProperty(String::class.java) + .convention(emptyList()) + + /** User-added exclude patterns (add-only, bare — no `!` prefix). */ + val exclude: ListProperty = + objectFactory.listProperty(String::class.java) + .convention(emptyList()) + + /** Include the plugin's default patterns for this slot. Default `true`. */ + val useDefaults: Property = + objectFactory.property(Boolean::class.java) + .convention(true) + + /** DSL sugar for `include.addAll(...)`, e.g. `include("a", "b")`. */ + fun include(vararg patterns: String) { + include.addAll(*patterns) + } + + /** DSL sugar for `exclude.addAll(...)`, e.g. `exclude("a", "b")`. */ + fun exclude(vararg patterns: String) { + exclude.addAll(*patterns) + } + } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/extension/AppQualityFoundationExtension.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/extension/AppQualityFoundationExtension.kt index 2984008..8802c9b 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/extension/AppQualityFoundationExtension.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/extension/AppQualityFoundationExtension.kt @@ -1,13 +1,18 @@ package ru.kode.android.app.quality.plugin.foundation.extension +import groovy.lang.Closure +import groovy.lang.DelegatesTo +import org.gradle.api.Action import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.tasks.Nested import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import ru.kode.android.app.quality.plugin.foundation.config.AndroidLintConfig import ru.kode.android.app.quality.plugin.foundation.config.DetektConfig import ru.kode.android.app.quality.plugin.foundation.config.KtlintConfig import ru.kode.android.build.publish.plugin.core.api.extension.BuildPublishConfigurableExtension +import ru.kode.android.build.publish.plugin.core.util.configureGroovy import javax.inject.Inject @Suppress("UnnecessaryAbstractClass") @@ -31,11 +36,58 @@ abstract class AppQualityFoundationExtension val gitHooks: RegularFileProperty = objectFactory.fileProperty() + /** + * Whether the plugin should point `git config core.hooksPath` at [gitHooks]. + * + * Set to `false` to opt out of git hooks setup entirely, e.g. when the consuming + * project manages `core.hooksPath` itself. Default value is `true`. + */ + val gitHooksEnabled: Property = + objectFactory.property(Boolean::class.java) + .convention(true) + @get:Nested val ktlint: KtlintConfig = objectFactory.newInstance(KtlintConfig::class.java) + fun ktlint(action: Action) { + action.execute(ktlint) + } + + fun ktlint( + @DelegatesTo(value = KtlintConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, ktlint) + } + @get:Nested val detekt: DetektConfig = objectFactory.newInstance(DetektConfig::class.java) + + fun detekt(action: Action) { + action.execute(detekt) + } + + fun detekt( + @DelegatesTo(value = DetektConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, detekt) + } + + @get:Nested + val androidLint: AndroidLintConfig = + objectFactory.newInstance(AndroidLintConfig::class.java) + + fun androidLint(action: Action) { + action.execute(androidLint) + } + + fun androidLint( + @DelegatesTo(value = AndroidLintConfig::class, strategy = Closure.DELEGATE_FIRST) + closure: Closure, + ) { + configureGroovy(closure, androidLint) + } } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/messages/Messages.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/messages/Messages.kt index 58e0848..263e2d9 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/messages/Messages.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/messages/Messages.kt @@ -62,50 +62,65 @@ fun mustBeUsedWithVersionMessage(version: AndroidPluginVersion): String { } /** - * Error message shown when the required dependency is not found in the version catalog. + * Error message shown when a file configured in a dependency slot does not exist on disk. + * [slot] is the fully qualified slot path, e.g. `detekt.kotlin.rules` or `ktlint.cli` — it is + * the only locus the user gets, since the failure surfaces from dependency resolution. */ -fun noKtlintDependencyReferenceInLibsMessage(name: String): String { +fun missingDependencyFileMessage( + file: File, + slot: String, +): String { return """ | |============================================================ - | MISSING KTLINT DEPENDENCY IN VERSION CATALOG + | MISSING DEPENDENCY FILE |============================================================ - | The required '$name' dependency is not defined in - | your libs.versions.toml file. + | A file configured in the '$slot' slot does not exist: | - | REQUIRED ACTION: - | 1. Add the following entries to your version catalog: + | ${file.absolutePath} | - | [versions] - | $name = "0.50.0" + | FIX — any one of: + | 1. Put the jar at that path + | 2. Fix the path in the root build script: | - | [libraries] - | $name = { module = "com.pinterest:ktlint", version.ref = "$name" } + | appQualityFoundation { + | $slot { + | from(files("path/to/your.jar")) + | } + | } | - | 2. Sync your project with Gradle files + | 3. Remove the entry if it is not needed |============================================================ """.trimMargin() } -fun noDetektRulesDependencyReferenceInLibsMessage(name: String): String { +/** + * Error message shown when a resolved detekt config activates the plugin's bundled `kode:` + * rule set but no dependency is wired into the matching `detekt..rules` slot. + */ +fun missingKodeRuleSetDependencyMessage( + platform: String, + configFile: File, +): String { return """ | |============================================================ - | MISSING DETEKT DEPENDENCY IN VERSION CATALOG + | MISSING DEPENDENCY FOR 'kode' RULE SET |============================================================ - | The required '$name' dependency is not defined in - | your libs.versions.toml file. - | - | REQUIRED ACTION: - | 1. Add the following entries to your version catalog: + | The detekt config activates the plugin's custom 'kode' + | rule set (e.g. RouteWiringMethodNaming), but no dependency + | is wired into 'detekt.$platform.rules': | - | [versions] - | detektLibName = "1.4.0" // replace with real name and version and use in ref value + | ${configFile.absolutePath} | - | [libraries] - | $name = { module = "ru.kode:detekt-rules-compose", version.ref = "detektLibName" } + | FIX — configure the slot in the root build script: | - | 2. Sync your project with Gradle files + | appQualityFoundation { + | detekt.$platform.rules { + | from(files("libs/detekt-rules-1.4.0.jar")) // checked-in jar + | // from(yourCatalog.detekt.rulesCompose) // or a catalog alias + | } + | } |============================================================ """.trimMargin() } @@ -117,15 +132,21 @@ fun noEditorConfigFileMessage(editorConfig: File): String { return """ | |============================================================ - | MISSING CONFIGURATION FILE + | MISSING CONFIGURATION FILE |============================================================ - | The required configuration file '${editorConfig.name}' - | was not found in the project root. + | The ktlint configuration file was not found: | - | REQUIRED ACTION: - | 1. Create a '${editorConfig.name}' file in your project - | root directory. - | 2. Configure your quality rules in this file. + | ${editorConfig.absolutePath} + | + | FIX — any one of: + | 1. Create '${editorConfig.name}' at that path and configure + | your formatting rules in it + | 2. Point the plugin at an existing file in the root + | build script: + | + | appQualityFoundation { + | ktlint.projectConfig.set(rootProject.layout.projectDirectory.file("config/.editorconfig")) + | } |============================================================ """.trimMargin() } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultConfigFileTask.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultConfigFileTask.kt new file mode 100644 index 0000000..020fd30 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultConfigFileTask.kt @@ -0,0 +1,29 @@ +package ru.kode.android.app.quality.plugin.foundation.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Materializes a bundled default config file (detekt config, ktlint editorconfig) + * into the build directory, so it can be used when a project-level file is absent. + * + * The content is an input: upgrading the plugin (new bundled defaults) reruns the task. + */ +@CacheableTask +abstract class GenerateDefaultConfigFileTask : DefaultTask() { + @get:Input + abstract val resourceContent: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + outputFile.get().asFile.writeText(resourceContent.get()) + } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultRulesJarTask.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultRulesJarTask.kt new file mode 100644 index 0000000..7a0bcbd --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GenerateDefaultRulesJarTask.kt @@ -0,0 +1,32 @@ +package ru.kode.android.app.quality.plugin.foundation.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.util.Base64 + +/** + * Materializes a bundled default rules jar (binary) into the build directory, so it can be + * used when a project hasn't configured its own dependency for the slot. + * + * Content travels as Base64 through a [Property] — the same content-is-the-input, + * config-cache-safe shape [GenerateDefaultConfigFileTask] uses for text resources, without + * relying on [ByteArray] property snapshotting. + */ +@CacheableTask +abstract class GenerateDefaultRulesJarTask : DefaultTask() { + @get:Input + abstract val resourceContentBase64: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + outputFile.get().asFile.writeBytes(Base64.getDecoder().decode(resourceContentBase64.get())) + } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GitHooksSetupTask.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GitHooksSetupTask.kt new file mode 100644 index 0000000..0a9e0d6 --- /dev/null +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/task/GitHooksSetupTask.kt @@ -0,0 +1,43 @@ +package ru.kode.android.app.quality.plugin.foundation.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask +import org.gradle.process.ExecOperations +import javax.inject.Inject + +/** + * Points `git config core.hooksPath` at the configured hooks directory. + * + * Skipped when the root project is not a git repository (fresh CI checkouts, source archives). + */ +@UntrackedTask(because = "Mutates local git configuration, which is not a build output") +abstract class GitHooksSetupTask : DefaultTask() { + @get:Input + abstract val hooksPath: Property + + @get:Internal + abstract val rootDir: DirectoryProperty + + @get:Inject + abstract val execOperations: ExecOperations + + init { + description = "Changes git core.hooksPath to the project hooks directory" + onlyIf("root project is a git repository") { task -> + (task as GitHooksSetupTask).rootDir.get().asFile.resolve(".git").exists() + } + } + + @TaskAction + fun setup() { + execOperations.exec { spec -> + spec.workingDir(rootDir.get().asFile) + spec.commandLine("git", "config", "core.hooksPath", hooksPath.get()) + } + } +} diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/utils/Extensions.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/utils/Extensions.kt index 08eab04..c371a7d 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/utils/Extensions.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/utils/Extensions.kt @@ -2,61 +2,159 @@ package ru.kode.android.app.quality.plugin.foundation.utils import org.gradle.api.GradleException import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.FileCollectionDependency import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider -import ru.kode.android.build.publish.plugin.core.logger.LoggerService +import ru.kode.android.app.quality.plugin.foundation.config.ExternalDependencyConfig +import ru.kode.android.app.quality.plugin.foundation.config.SourcePatternsConfig +import java.io.File -internal fun kotlinSourcePatterns(additionalSourcePatterns: List = emptyList()): List { - return listOf( +internal const val VERSION_CATALOG_NAME = "libs" + +private val DEFAULT_KOTLIN_INCLUDE_PATTERNS = + listOf( "**/src/*/java/**/*.kt", "**/src/*/kotlin/**/*.kt", - ) + additionalSourcePatterns -} + ) -internal fun ignoredSourcePatterns(additionalIgnoredSourcePatterns: List = emptyList()): List { - return listOf( - "!**/build/**", - "!**/generated/**", - "!**/templates/**", - "!**/src/test/**", - "!**/src/androidTest/**", - "!**/src/commonTest/**", - "!templates/**", - "!**/schema/**/*.kt", - ) + additionalIgnoredSourcePatterns -} +private val DEFAULT_KOTLIN_EXCLUDE_PATTERNS = + listOf( + "**/build/**", + "**/generated/**", + "**/templates/**", + "**/src/test/**", + "**/src/androidTest/**", + "**/src/commonTest/**", + "templates/**", + "**/schema/**/*.kt", + ) -internal fun Project.libs(catalogName: String): VersionCatalog { - return this.extensions.getByType(VersionCatalogsExtension::class.java) - .named(catalogName) +/** + * Builds the ktlint include-pattern list from [sources]: the bundled Kotlin globs (while + * `useDefaults` is true) plus any user-added `include` patterns. + */ +internal fun kotlinSourcePatterns(sources: SourcePatternsConfig): Provider> { + return sources.useDefaults.flatMap { useDefaults -> + sources.include.map { (if (useDefaults) DEFAULT_KOTLIN_INCLUDE_PATTERNS else emptyList()) + it } + } } -internal fun Project.resolveFile( - projectRegularFile: RegularFile, - fallbackRegularFile: RegularFile, - defaultFile: String, - loggerProvider: Provider, -): Provider { - if (projectRegularFile.asFile.exists()) return provider { projectRegularFile } - - val logger = loggerProvider.get() - val fallbackFile = fallbackRegularFile.asFile +/** + * Builds the ktlint ignore-pattern list from [sources]: the bundled ignore globs (while + * `useDefaults` is true) plus any user-added `exclude` patterns — each prefixed with `!` + * for the ktlint-CLI ignore syntax (the DSL itself takes bare patterns). + */ +internal fun ignoredSourcePatterns(sources: SourcePatternsConfig): Provider> { + return sources.useDefaults.flatMap { useDefaults -> + sources.exclude.map { excluded -> + ((if (useDefaults) DEFAULT_KOTLIN_EXCLUDE_PATTERNS else emptyList()) + excluded) + .map { "!$it" } + } + } +} - if (!fallbackFile.exists()) { - fallbackFile.parentFile.mkdirs() +/** + * Lazily resolves a library: a matching alias in the consumer's own `libs` version catalog if + * present, otherwise [fallbackCoordinate] baked into the plugin. The lookup is deferred until + * the returned provider is queried, so a project that never runs the consuming task — or that + * configures the dependency explicitly — never pays for it. Never fails: an absent catalog or + * alias is exactly when the fallback applies. + */ +internal fun Project.catalogLibraryOrDefault( + alias: String, + fallbackCoordinate: String, +): Provider { + return providers.provider { + val fromCatalog: Provider? = + extensions.findByType(VersionCatalogsExtension::class.java) + ?.find(VERSION_CATALOG_NAME) + ?.orElse(null) + ?.findLibrary(alias) + ?.orElse(null) + ?.map { it } + fromCatalog ?: providers.provider { dependencies.create(fallbackCoordinate) } + }.flatMap { it } +} - PluginResources::class.java.getResourceAsStream(defaultFile) - ?.use { input -> - fallbackFile.outputStream().use { output -> - input.copyTo(output) - logger.info("Copied ${input.available()} bytes to $fallbackFile") +/** + * Wires an [ExternalDependencyConfig] slot into a configuration — the ONE mechanism every + * external-dependency slot of the plugin uses. User additions (any source kind) are wired + * with fail-fast validation of file entries; the slot's defaults are added independently, + * controlled by `useDefaults`. + */ +internal fun Project.wireDependencies( + configuration: Configuration, + slot: ExternalDependencyConfig, + missingFileMessage: (File) -> String, +) { + // Validation must stay side-effect-free (read + throw only) for configuration-cache safety. + configuration.dependencies.addAllLater( + slot.from.dependencies.map { dependencies -> + dependencies.onEach { dependency -> + if (dependency is FileCollectionDependency) { + dependency.files.forEach { file -> + if (!file.exists()) throw GradleException(missingFileMessage(file)) + } } } - ?: throw GradleException("Default file ($defaultFile) not found in plugin resources") - } + }, + ) + // Match fromDependencyCollector semantics: constraints added via from.addConstraint are wired too. + configuration.dependencyConstraints.addAllLater(slot.from.dependencyConstraints) + configuration.dependencies.addAllLater( + // flatMap, NOT zip: defaults are throwing catalog lookups and must never be + // evaluated when useDefaults is false. + slot.useDefaults.flatMap { use -> + if (use) slot.defaults else providers.provider { emptyList() } + }, + ) + // File-based defaults (see ExternalDependencyConfig.defaultFiles) go through the same + // DependencyCollector mechanism as `from`, not the plain `defaults` ListProperty above — + // required for configuration-cache compatibility. + configuration.dependencies.addAllLater( + slot.useDefaults.flatMap { use -> + if (use) slot.defaultFiles.dependencies else providers.provider { emptySet() } + }, + ) + configuration.dependencyConstraints.addAllLater( + slot.useDefaults.flatMap { use -> + if (use) slot.defaultFiles.dependencyConstraints else providers.provider { emptySet() } + }, + ) +} + +/** + * Resolves a config file with the plugin's standard priority: + * 1. an explicit user override from the extension, + * 2. the conventional project-level file, if it exists, + * 3. the bundled default materialized by a [GenerateDefaultConfigFileTask] + * (the provider carries the task dependency). + * + * The existence probe of [candidate] runs when the provider is queried; Gradle tracks it + * as a configuration input, so creating the file later correctly invalidates the + * configuration cache. Nothing is written at configuration time. + */ +internal fun Project.resolveConfigFile( + override: Provider, + candidate: RegularFile, + bundledDefault: Provider, +): Provider { + val candidateIfExists = providers.provider { candidate.takeIf { it.asFile.exists() } } + return override + .orElse(candidateIfExists) + .orElse(bundledDefault) +} + +private val KODE_RULE_SET_KEY_REGEX = Regex("(?m)^kode:") - logger.info("Using default file ($fallbackFile from AppQualityFoundationPlugin") - return provider { fallbackRegularFile } +/** + * Whether a resolved detekt config activates the plugin's bundled custom `kode:` rule set + * (e.g. `RouteWiringMethodNaming`) — top-level YAML key, anchored so it doesn't false-positive + * on unrelated `kode`-containing text elsewhere in the file (e.g. `ru.kode.*` package names). + */ +internal fun activatesKodeRuleSet(configFile: File): Boolean { + return KODE_RULE_SET_KEY_REGEX.containsMatchIn(configFile.readText()) } diff --git a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/validate/AgpVersionsValidator.kt b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/validate/AgpVersionsValidator.kt index df28826..ebfaf3b 100644 --- a/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/validate/AgpVersionsValidator.kt +++ b/plugin-build/plugin-foundation/src/main/kotlin/ru/kode/android/app/quality/plugin/foundation/validate/AgpVersionsValidator.kt @@ -40,3 +40,19 @@ internal fun Project.stopExecutionIfNotSupported() { object AgpVersions { val MIN_VERSION = AndroidPluginVersion(7, 4, 0) } + +/** + * Validates the AGP version of a subproject applying `com.android.application`/ + * `com.android.library` directly (the foundation plugin itself is usually applied at the + * root only, so [stopExecutionIfNotSupported] never sees these). Called from the detekt + * per-subproject wiring, where an Android plugin's presence is already being checked. + */ +internal fun Project.validateSubprojectAgpVersion() { + val androidComponents = + extensions.findByType(AndroidComponentsExtension::class.java) + ?: return + val current = androidComponents.pluginVersion + if (current < MIN_VERSION) { + throw StopExecutionException(mustBeUsedWithVersionMessage(MIN_VERSION)) + } +} diff --git a/plugin-build/plugin-foundation/src/main/resources/detekt/default.kotlin-config.yml b/plugin-build/plugin-foundation/src/main/resources/detekt/default.kotlin-config.yml index 4356f8a..b80be11 100644 --- a/plugin-build/plugin-foundation/src/main/resources/detekt/default.kotlin-config.yml +++ b/plugin-build/plugin-foundation/src/main/resources/detekt/default.kotlin-config.yml @@ -35,9 +35,6 @@ console-reports: - 'FindingsReport' - 'FileBasedFindingsReport' -output-reports: - active: false - comments: active: true AbsentOrWrongFileLicense: diff --git a/plugin-build/plugin-foundation/src/main/resources/detekt/rules/kode-android-rules-1.4.0.jar b/plugin-build/plugin-foundation/src/main/resources/detekt/rules/kode-android-rules-1.4.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..1403a5a23e272adee9ba72cd38bacad484a65d5b GIT binary patch literal 33166 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 z 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 literal 0 HcmV?d00001 diff --git a/plugin-build/settings.gradle.kts b/plugin-build/settings.gradle.kts index f748773..f347a58 100644 --- a/plugin-build/settings.gradle.kts +++ b/plugin-build/settings.gradle.kts @@ -23,5 +23,6 @@ dependencyResolutionManagement { rootProject.name = ("ru.kode.android.app.quality.plugin") -include(":plugin-foundation") +include(":app-quality-foundation") +project(":app-quality-foundation").projectDir = file("plugin-foundation") includeBuild("../build-conventions") diff --git a/plugin-test/build.gradle.kts b/plugin-test/build.gradle.kts index 438ed95..b6c1851 100644 --- a/plugin-test/build.gradle.kts +++ b/plugin-test/build.gradle.kts @@ -1,8 +1,6 @@ @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.kotlin) apply false - alias(libs.plugins.ksp) apply false - alias(libs.plugins.grgit) apply false } allprojects { diff --git a/plugin-test/foundation/build.gradle.kts b/plugin-test/foundation/build.gradle.kts index efffa63..5035d19 100644 --- a/plugin-test/foundation/build.gradle.kts +++ b/plugin-test/foundation/build.gradle.kts @@ -1,18 +1,23 @@ plugins { id("kotlin-convention") id("java-gradle-plugin") - id("com.google.devtools.ksp") } dependencies { implementation(libs.agp) + implementation(libs.plugin.foundation) + // Lets generated test projects apply org.jetbrains.kotlin.plugin.compose from the + // injected classpath; the version must match the kotlin-gradle-plugin resolved there. + implementation(libs.compose.compiler.plugin) + // Lets generated test projects apply org.jetbrains.compose (JetBrains Compose + // Multiplatform) from the injected classpath. + implementation(libs.compose.multiplatform.plugin) testImplementation(libs.plugin.core) testImplementation(project(":utils")) testImplementation(gradleApi()) testImplementation(libs.grgitCore) - testImplementation(libs.grgitGradle) testImplementation(gradleTestKit()) testImplementation(platform(libs.junitBom)) diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksTest.kt new file mode 100644 index 0000000..edca7ef --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AggregateTasksTest.kt @@ -0,0 +1,226 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.initGit +import ru.kode.android.app.quality.plugin.test.utils.resolveRequiredAgpJars +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTasks +import java.io.File + +class AggregateTasksTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + private fun kotlinModule(name: String) = + ModuleSpec( + name = name, + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ) + + @Test + fun `pipelineCheck runs subproject detekt tasks`() { + projectDir.createQualityProject(modules = listOf(kotlinModule("a"))) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + val detektTasks = result.tasks.map { it.path }.filter { it.startsWith(":a:detekt") } + assertTrue(detektTasks.isNotEmpty(), "pipelineCheck must trigger detekt tasks of subprojects") + } + + @Test + fun `pipelineCheck on android module runs variant detekt tasks except ignored build types`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "app", + type = ModuleType.AndroidApp, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(detekt = kodeRulesJarBlock()), + rulesJar = exampleRulesJar(), + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + val detektTasks = result.tasks.map { it.path }.filter { it.startsWith(":app:detekt") } + assertTrue(detektTasks.isNotEmpty(), "pipelineCheck must trigger detekt tasks of the app module") + assertTrue( + detektTasks.none { it.contains("release", ignoreCase = true) }, + "release detekt tasks must be excluded by default ignoredBuildTypes, got: $detektTasks", + ) + } + + // Variant detekt tasks (detektDebug/detektRelease) only exist with the classic + // org.jetbrains.kotlin.android setup: detekt 1.x does not support AGP 9 built-in Kotlin + // (https://github.com/detekt/detekt/issues/8320 — fixed only in detekt 2.0.0-alpha.3+, + // which moved to dev.detekt coordinates). So this scenario runs on an injected AGP 8.x + // with an older Gradle, the same trick build-publish uses for AGP/Gradle matrices. + @Test + fun `custom ignoredBuildTypes excludes matching variant detekt tasks with legacy AGP`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "app", + type = ModuleType.AndroidApp, + buildTypes = listOf("internal", "demo"), + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + compileSdk = 35, + applyKotlinAndroidPlugin = true, + ), + ), + qualityConfig = + QualityConfig( + detekt = kodeRulesJarBlock().copy(ignoredBuildTypes = listOf("debug")), + ), + rulesJar = exampleRulesJar(), + ) + projectDir.initGit() + + val result = + projectDir.runTasks( + "pipelineCheck", + agpClasspath = resolveRequiredAgpJars(LEGACY_AGP_VERSION), + gradleVersion = LEGACY_GRADLE_VERSION, + ) + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + val detektTasks = result.tasks.map { it.path }.filter { it.startsWith(":app:detekt") } + assertTrue( + detektTasks.any { it != ":app:detekt" }, + "expected variant detekt tasks with the kotlin-android plugin, got: $detektTasks", + ) + assertTrue( + detektTasks.none { it.contains("debug", ignoreCase = true) }, + "debug detekt tasks must be excluded by custom ignoredBuildTypes, got: $detektTasks", + ) + // Custom ignoredBuildTypes REPLACES the default list ([release, internal, external, + // demo]) — build types no longer ignored get variant tasks again. + listOf("release", "internal", "demo").forEach { buildType -> + assertTrue( + detektTasks.any { it.contains(buildType, ignoreCase = true) }, + "$buildType detekt tasks must run when only debug is ignored, got: $detektTasks", + ) + } + } + + @Test + fun `prePushCheck runs ktlintFormat instead of ktlintCheck`() { + projectDir.createQualityProject(modules = listOf(kotlinModule("a"))) + projectDir.initGit() + + val result = projectDir.runTask("prePushCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":prePushCheck")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintFormat")?.outcome) + assertEquals(null, result.task(":ktlintCheck"), "prePushCheck must not run ktlintCheck") + } + + @Test + fun `printRequiredGradleJvmargs prints the running JVM's input arguments`() { + projectDir.createQualityProject(modules = listOf(kotlinModule("a"))) + projectDir.initGit() + + val result = projectDir.runTask("printRequiredGradleJvmargs") + + assertEquals(TaskOutcome.SUCCESS, result.task(":printRequiredGradleJvmargs")?.outcome) + assertTrue(result.output.contains("Args: "), "expected the 'Args: ' line, got: ${result.output}") + } + + @Test + fun `androidLint is off by default and does not wire the lint task into pipelineCheck`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "app", + type = ModuleType.AndroidApp, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(detekt = kodeRulesJarBlock()), + rulesJar = exampleRulesJar(), + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + assertTrue( + result.tasks.none { it.path.startsWith(":app:lint") }, + "pipelineCheck must not trigger AGP lint tasks when androidLint is left at its default", + ) + } + + @Test + fun `androidLint enabled wires the lint task into pipelineCheck and prePushCheck`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "app", + type = ModuleType.AndroidApp, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = kodeRulesJarBlock(), + extraExtensionContent = "androidLint.enabled.set(true)", + ), + rulesJar = exampleRulesJar(), + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + assertTrue( + result.tasks.any { it.path.startsWith(":app:lint") }, + "pipelineCheck must trigger AGP lint tasks once androidLint.enabled is set, got: ${result.tasks.map { it.path }}", + ) + } + + @Test + fun `aggregate tasks are listed in the verification group`() { + projectDir.createQualityProject(modules = listOf(kotlinModule("a"))) + projectDir.initGit() + + val result = projectDir.runTask("tasks") + + val verificationSection = + result.output + .substringAfter("Verification tasks", missingDelimiterValue = "") + .substringBefore("\n\n") + assertTrue( + verificationSection.contains("pipelineCheck"), + "pipelineCheck must be listed under Verification tasks", + ) + assertTrue( + verificationSection.contains("prePushCheck"), + "prePushCheck must be listed under Verification tasks", + ) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AgpVersionsValidatorTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AgpVersionsValidatorTest.kt new file mode 100644 index 0000000..211fca2 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/AgpVersionsValidatorTest.kt @@ -0,0 +1,232 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.getFile +import ru.kode.android.app.quality.plugin.test.utils.resolveRequiredAgpJars +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import ru.kode.android.app.quality.plugin.test.utils.runTasks +import java.io.File + +/** + * The plugin's documented usage applies it at the ROOT project, where AGP's + * `AndroidComponentsExtension` never exists (it's only registered on modules that apply AGP + * themselves) — so `stopExecutionIfNotSupported` never fires there. These tests instead exercise + * the guard the only way it's reachable: applying the plugin directly to an Android module. + */ +class AgpVersionsValidatorTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + private fun writeSettings() { + projectDir.getFile("settings.gradle").writeText( + """ + pluginManagement { + repositories { + mavenLocal() + google() + mavenCentral() + gradlePluginPortal() + } + } + dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + } + } + rootProject.name = "agp-validator-test" + """.trimIndent(), + ) + } + + private fun writeManifest() { + projectDir.getFile("src/main/AndroidManifest.xml").writeText( + """ + + + """.trimIndent(), + ) + } + + @Test + fun `plugin applied directly to a library module fails with the actionable message`() { + writeSettings() + writeManifest() + projectDir.getFile("build.gradle").writeText( + """ + plugins { + id 'com.android.library' + id 'ru.kode.android.app-quality.foundation' + } + + android { + namespace = "ru.kode.test.agpvalidator" + compileSdk 36 + defaultConfig { + minSdk 26 + } + } + """.trimIndent(), + ) + + // com.android.library registers AndroidComponentsExtension but has no AppPlugin — + // the guard's second check (`!plugins.hasPlugin(AppPlugin::class.java)`) must fire. + val result = projectDir.runTaskWithFail("help") + + assertTrue( + result.output.contains("PLUGIN CONFIGURATION ERROR"), + "expected the mustBeUsedWithAndroidMessage banner, got: ${result.output}", + ) + assertTrue( + result.output.contains("This plugin can only be used with Android application"), + "expected the specific guidance text", + ) + } + + @Test + fun `plugin applied with an AGP version below the minimum fails with the version message`() { + writeSettings() + writeManifest() + projectDir.getFile("build.gradle").writeText( + """ + plugins { + id 'com.android.application' + id 'ru.kode.android.app-quality.foundation' + } + + android { + namespace = "ru.kode.test.agpvalidator" + compileSdk 33 + defaultConfig { + applicationId "ru.kode.test.agpvalidator" + minSdk 26 + targetSdk 33 + versionCode 1 + versionName "1.0" + } + } + """.trimIndent(), + ) + + // AgpVersions.MIN_VERSION is 7.4.0 — 7.3.1 has AppPlugin but must fail the version + // check first (it runs before the AppPlugin check in stopExecutionIfNotSupported). + val result = + projectDir.runTasks( + "help", + agpClasspath = resolveRequiredAgpJars(BELOW_MIN_AGP_VERSION), + gradleVersion = BELOW_MIN_AGP_GRADLE_VERSION, + expectFailure = true, + ) + + assertTrue( + result.output.contains("UNSUPPORTED ANDROID GRADLE PLUGIN VERSION"), + "expected the mustBeUsedWithVersionMessage banner, got: ${result.output}", + ) + } + + @Test + fun `plugin applied directly to an app module with a supported AGP version does not fail`() { + writeSettings() + writeManifest() + projectDir.getFile("build.gradle").writeText( + """ + plugins { + id 'com.android.application' + id 'ru.kode.android.app-quality.foundation' + } + + android { + namespace = "ru.kode.test.agpvalidator" + compileSdk 36 + defaultConfig { + applicationId "ru.kode.test.agpvalidator" + minSdk 26 + targetSdk 36 + versionCode 1 + versionName "1.0" + } + } + """.trimIndent(), + ) + + // A supported AGP version + com.android.application (AppPlugin present) — the guard's + // both checks pass and stopExecutionIfNotSupported must not throw. + val result = projectDir.runTask("help") + + assertEquals(TaskOutcome.SUCCESS, result.task(":help")?.outcome) + assertTrue( + !result.output.contains("PLUGIN CONFIGURATION ERROR"), + "did not expect the mustBeUsedWithAndroidMessage banner, got: ${result.output}", + ) + assertTrue( + !result.output.contains("UNSUPPORTED ANDROID GRADLE PLUGIN VERSION"), + "did not expect the mustBeUsedWithVersionMessage banner, got: ${result.output}", + ) + } + + @Test + fun `subproject applying AGP directly with an unsupported version fails validation`() { + // Plugin applied only at the root (its documented usage) — the subproject applies + // com.android.library on its own, which is the path validateSubprojectAgpVersion covers. + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec(name = "a", type = ModuleType.AndroidLib), + ), + ) + + val result = + projectDir.runTasks( + "help", + agpClasspath = resolveRequiredAgpJars(BELOW_MIN_AGP_VERSION), + gradleVersion = BELOW_MIN_AGP_GRADLE_VERSION_MULTI_MODULE, + expectFailure = true, + ) + + assertTrue( + result.output.contains("UNSUPPORTED ANDROID GRADLE PLUGIN VERSION"), + "expected the mustBeUsedWithVersionMessage banner from the subproject, got: ${result.output}", + ) + } + + @Test + fun `subproject applying AGP directly with a supported version does not fail`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec(name = "a", type = ModuleType.AndroidLib), + ), + ) + + val result = projectDir.runTask("help") + + assertEquals(TaskOutcome.SUCCESS, result.task(":help")?.outcome) + assertTrue( + !result.output.contains("UNSUPPORTED ANDROID GRADLE PLUGIN VERSION"), + "did not expect the mustBeUsedWithVersionMessage banner, got: ${result.output}", + ) + } + + private companion object { + const val BELOW_MIN_AGP_VERSION = "7.3.1" + const val BELOW_MIN_AGP_GRADLE_VERSION = "8.6" + const val BELOW_MIN_AGP_GRADLE_VERSION_MULTI_MODULE = "8.7" + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigurationCacheTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigurationCacheTest.kt new file mode 100644 index 0000000..5d0a584 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ConfigurationCacheTest.kt @@ -0,0 +1,159 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.getFile +import ru.kode.android.app.quality.plugin.test.utils.initGit +import ru.kode.android.app.quality.plugin.test.utils.runTasks +import java.io.File + +class ConfigurationCacheTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `second pipelineCheck run reuses the configuration cache`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + projectDir.initGit() + + val first = projectDir.runTasks("pipelineCheck", arguments = listOf("--configuration-cache")) + assertEquals(TaskOutcome.SUCCESS, first.task(":pipelineCheck")?.outcome) + + val second = projectDir.runTasks("pipelineCheck", arguments = listOf("--configuration-cache")) + assertTrue( + second.output.contains("Reusing configuration cache"), + "second run must reuse the configuration cache", + ) + } + + @Test + fun `creating a module detekt config file after a cached run is picked up`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + ) + projectDir.initGit() + + // Bundled default (120 max) -> passes and stores the configuration cache. + val first = projectDir.runTasks(":a:detekt", arguments = listOf("--configuration-cache")) + assertEquals(TaskOutcome.SUCCESS, first.task(":a:detekt")?.outcome) + + // A stricter module config appears -> the next run must use it and fail. + projectDir.getFile("a/detekt-kotlin-config.yml").writeText(Configs.DETEKT_MAX_LINE_60) + + val second = + projectDir.runTasks( + ":a:detekt", + arguments = listOf("--configuration-cache"), + expectFailure = true, + ) + assertEquals(TaskOutcome.FAILED, second.task(":a:detekt")?.outcome) + assertTrue( + second.output.contains("MaxLineLength"), + "expected the newly created module config to be applied", + ) + } + + @Test + fun `second detekt-only run reuses the configuration cache`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + projectDir.initGit() + + val first = projectDir.runTasks(":a:detekt", arguments = listOf("--configuration-cache")) + assertEquals(TaskOutcome.SUCCESS, first.task(":a:detekt")?.outcome) + + val second = projectDir.runTasks(":a:detekt", arguments = listOf("--configuration-cache")) + assertTrue( + second.output.contains("Reusing configuration cache"), + "second detekt-only run must reuse the configuration cache", + ) + } + + @Test + fun `second ktlintCheck-only run reuses the configuration cache`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + projectDir.initGit() + + val first = projectDir.runTasks("ktlintCheck", arguments = listOf("--configuration-cache")) + assertEquals(TaskOutcome.SUCCESS, first.task(":ktlintCheck")?.outcome) + + val second = projectDir.runTasks("ktlintCheck", arguments = listOf("--configuration-cache")) + assertTrue( + second.output.contains("Reusing configuration cache"), + "second ktlintCheck-only run must reuse the configuration cache", + ) + } + + @Test + fun `second run with typeResolution enabled reuses the configuration cache`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(detekt = DetektBlock(typeResolution = true)), + ) + projectDir.initGit() + + val first = projectDir.runTasks(":a:detekt", arguments = listOf("--configuration-cache")) + assertEquals(TaskOutcome.SUCCESS, first.task(":a:detekt")?.outcome) + + val second = projectDir.runTasks(":a:detekt", arguments = listOf("--configuration-cache")) + assertTrue( + second.output.contains("Reusing configuration cache"), + "second run with typeResolution=true must reuse the configuration cache", + ) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DependencyWiringTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DependencyWiringTest.kt new file mode 100644 index 0000000..235f926 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DependencyWiringTest.kt @@ -0,0 +1,535 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.foundation.messages.missingDependencyFileMessage +import ru.kode.android.app.quality.plugin.foundation.messages.noEditorConfigFileMessage +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.KtlintBlock +import ru.kode.android.app.quality.plugin.test.utils.LibsCatalog +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.PlatformDetektBlock +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.resolveJars +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import java.io.File + +/** + * Covers the unified dependency slots (ExternalDependencyConfig): every source kind through + * one `from(...)` API, defaults + useDefaults semantics, and the actionable error messages. + */ +class DependencyWiringTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + private fun kotlinModule(name: String = "a") = + ModuleSpec( + name = name, + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ) + + @Test + fun `typed accessors from a custom-named catalog work without any libs catalog`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + ktlint = + KtlintBlock( + cli = DependencySlot(refs = listOf("deps.ktlint.cli"), useDefaults = false), + ), + detekt = + DetektBlock( + kotlin = + PlatformDetektBlock( + rules = + DependencySlot( + refs = listOf("deps.detekt.formatting"), + useDefaults = false, + ), + ), + ), + ), + libsCatalog = LibsCatalog(name = "deps"), + ) + + val result = projectDir.runTask(":a:detekt") + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + + val ktlintResult = projectDir.runTask("ktlintCheck") + assertEquals(TaskOutcome.SUCCESS, ktlintResult.task(":ktlintCheck")?.outcome) + } + + @Test + fun `rules by string coordinates work without catalog alias`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + kotlin = + PlatformDetektBlock( + rules = + DependencySlot( + notations = listOf("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8"), + useDefaults = false, + ), + ), + ), + ), + libsCatalog = LibsCatalog(includeDetektFormatting = false), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `published compose rules consumed by coordinates without catalog alias`() { + // Simulates the publish-and-consume flow for custom rules: the compose module's + // bundled config validates its `compose:` rule set only when the published + // ru.kode:detekt-rules-compose artifact actually lands on detektPlugins. + projectDir.createQualityProject( + modules = listOf(kotlinModule().copy(applyComposePlugin = true)), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + compose = + PlatformDetektBlock( + rules = + DependencySlot( + notations = listOf("ru.kode:detekt-rules-compose:1.4.0"), + useDefaults = false, + ), + ), + ), + ), + libsCatalog = LibsCatalog(includeDetektComposeRules = false), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `ktlint cli by string coordinates works without catalog alias`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + ktlint = + KtlintBlock( + cli = + DependencySlot( + notations = listOf("com.pinterest.ktlint:ktlint-cli:1.8.0"), + useDefaults = false, + ), + ), + ), + libsCatalog = LibsCatalog(includeKtlintCli = false), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `ktlint cli from jar files works without any catalog alias`() { + // Plain resolution yields the thin CLI jar + its transitives — provide the whole set, + // which is exactly the checked-in-jars use case. + val cliJars = resolveJars("com.pinterest.ktlint:ktlint-cli:1.8.0") + assumeTrue(cliJars.isNotEmpty(), "could not resolve the ktlint-cli jars; skipping") + + val toolPaths = + cliJars.map { jar -> + jar.copyTo(File(projectDir, "tools/${jar.name}").also { it.parentFile.mkdirs() }, overwrite = true) + "tools/${jar.name}" + } + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(cli = DependencySlot(files = toolPaths, useDefaults = false)), + ), + libsCatalog = LibsCatalog(includeKtlintCli = false), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `custom rules jar stacks on top of the default formatting rules`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + // Defaults stay ON: the kode rule set must validate (custom jar present) AND the + // formatting rule set must validate + fire (default detekt-formatting present) — + // both artifacts active on detektPlugins at once. + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_KODE_AND_FORMATTING, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + qualityConfig = QualityConfig(detekt = kodeRulesJarBlock()), + rulesJar = rulesJar, + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue( + result.output.contains("Indentation"), + "expected the default detekt-formatting Indentation rule to fire alongside the custom jar", + ) + } + + @Test + fun `useDefaults false drops the default formatting rules`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + // Same setup as above but defaults OFF: detekt-formatting is gone, so the config's + // `formatting:` section now fails validation as an unknown rule set — while the kode + // section still validates via the explicitly configured jar. + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_KODE_AND_FORMATTING, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + kotlin = + PlatformDetektBlock( + rules = + DependencySlot( + files = listOf("libs/detekt-rules-1.4.0.jar"), + useDefaults = false, + ), + ), + ), + ), + rulesJar = rulesJar, + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue( + result.output.contains("formatting"), + "expected a validation error about the now-unknown formatting rule set", + ) + assertTrue( + !result.output.contains("Indentation - ["), + "the formatting rules must not have run with defaults disabled", + ) + } + + @Test + fun `nested groovy closure blocks configure the extension end to end`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + // Exercises the Closure overloads of every level: ktlint { cli { ... } } and + // detekt { kotlin { rules { ... } } } in a plain Groovy script — the kode rule set + // only validates if the jar wired through the nested blocks reaches detektPlugins. + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_KODE_RULE, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + extraExtensionContent = + """ + ktlint { + cli { + useDefaults.set(true) + } + } + detekt { + kotlin { + rules { + from(files(rootProject.layout.projectDirectory.file("libs/detekt-rules-1.4.0.jar"))) + } + } + } + """, + ), + rulesJar = rulesJar, + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `missing configured rules file fails with actionable message naming the slot`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + kotlin = PlatformDetektBlock(rules = DependencySlot(files = listOf("libs/nope.jar"))), + ), + ), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue(result.output.contains("MISSING DEPENDENCY FILE"), "expected the missing file banner") + assertTrue( + result.output.contains("detekt.kotlin.rules"), + "expected the fully qualified slot in the message", + ) + assertTrue(result.output.contains("from(files("), "expected the from(files(...)) fix snippet") + val expectedMessage = + missingDependencyFileMessage(File(projectDir, "libs/nope.jar").canonicalFile, "detekt.kotlin.rules") + assertTrue( + result.output.contains(expectedMessage), + "expected the verbatim missingDependencyFileMessage banner in the output", + ) + } + + @Test + fun `missing configured cli file fails with actionable message naming the slot`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(cli = DependencySlot(files = listOf("tools/nope.jar"))), + ), + ) + + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertTrue(result.output.contains("MISSING DEPENDENCY FILE"), "expected the missing file banner") + assertTrue(result.output.contains("ktlint.cli"), "expected the fully qualified slot in the message") + val expectedMessage = + missingDependencyFileMessage(File(projectDir, "tools/nope.jar").canonicalFile, "ktlint.cli") + assertTrue( + result.output.contains(expectedMessage), + "expected the verbatim missingDependencyFileMessage banner in the output", + ) + } + + @Test + fun `no version catalog at all still succeeds via the baked-in defaults`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + libsCatalog = LibsCatalog(generate = false), + ) + + // No libs.versions.toml at all — every default slot falls back to its baked-in + // coordinate instead of failing. + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `missing ktlint-cli alias still succeeds via the baked-in default`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + libsCatalog = LibsCatalog(includeKtlintCli = false), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `missing detekt-formatting alias still succeeds via the baked-in default`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + libsCatalog = LibsCatalog(includeDetektFormatting = false), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `missing detekt-compose-rules alias still succeeds a compose module via the baked-in default`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule().copy(applyComposePlugin = true)), + libsCatalog = LibsCatalog(includeDetektComposeRules = false), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `from with a pinned version coordinate still resolves cleanly`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + extraExtensionContent = + """ + ktlint { + cli { + from("com.pinterest.ktlint:ktlint-cli:1.8.0") + useDefaults.set(false) + } + } + """, + ), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `ktlint-cli catalog alias wins over the baked-in default when present`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + libsCatalog = LibsCatalog(generate = false), + extraRootFiles = + mapOf( + "gradle/libs.versions.toml" to + """ + [versions] + ktlintCli = "1.8.0" + + [libraries] + ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli-BOGUS", version.ref = "ktlintCli" } + """.trimIndent(), + ), + ) + + // A bogus coordinate under the real alias name only breaks the build if the catalog + // alias was actually used instead of the plugin's baked-in default. + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertTrue( + result.output.contains("ktlint-cli-BOGUS"), + "expected resolution to attempt the catalog's coordinate, got: ${result.output}", + ) + } + + @Test + fun `detekt-formatting catalog alias wins over the baked-in default when present`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + libsCatalog = LibsCatalog(generate = false), + extraRootFiles = + mapOf( + "gradle/libs.versions.toml" to + """ + [versions] + detekt = "1.23.8" + + [libraries] + detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting-BOGUS", version.ref = "detekt" } + """.trimIndent(), + ), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue( + result.output.contains("detekt-formatting-BOGUS"), + "expected resolution to attempt the catalog's coordinate, got: ${result.output}", + ) + } + + @Test + fun `detekt-compose-rules catalog alias wins over the baked-in default when present`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule().copy(applyComposePlugin = true)), + libsCatalog = LibsCatalog(generate = false), + extraRootFiles = + mapOf( + "gradle/libs.versions.toml" to + """ + [versions] + detektComposeRules = "1.4.0" + + [libraries] + detekt-compose-rules = { module = "ru.kode:detekt-rules-compose-BOGUS", version.ref = "detektComposeRules" } + """.trimIndent(), + ), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue( + result.output.contains("detekt-rules-compose-BOGUS"), + "expected resolution to attempt the catalog's coordinate, got: ${result.output}", + ) + } + + @Test + fun `explicitly configured missing editorconfig fails with actionable message`() { + projectDir.createQualityProject( + modules = listOf(kotlinModule()), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(projectConfigPath = "config/nope.editorconfig"), + ), + ) + + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertTrue( + result.output.contains("MISSING CONFIGURATION FILE"), + "expected the missing editorconfig banner", + ) + assertTrue( + result.output.contains("ktlint.projectConfig.set"), + "expected the projectConfig fix option", + ) + val expectedMessage = noEditorConfigFileMessage(File(projectDir, "config/nope.editorconfig").canonicalFile) + assertTrue( + result.output.contains(expectedMessage), + "expected the verbatim noEditorConfigFileMessage banner in the output", + ) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektConfigurationTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektConfigurationTest.kt new file mode 100644 index 0000000..38f9513 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/DetektConfigurationTest.kt @@ -0,0 +1,834 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.foundation.messages.missingKodeRuleSetDependencyMessage +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.LibsCatalog +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.PlatformDetektBlock +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.SourcePatternsSlot +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import ru.kode.android.app.quality.plugin.test.utils.runTasks +import java.io.File + +class DetektConfigurationTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `module-local detekt-kotlin-config yml is used for that module`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue(result.output.contains("MaxLineLength"), "expected MaxLineLength violation from module config") + } + + @Test + fun `bundled default kotlin config is used when module has no config file`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + ) + + // Bundled default allows up to 120 chars — the 80-char line must pass. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + // Regression guard: per-module config resolution goes through the shared extension object today; + // behaviorally each module gets its own config (verified) — this must stay true after the rework. + @Test + fun `module without config is not contaminated by sibling module's config`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ModuleSpec( + name = "b", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + ) + + // b has no module config -> bundled default (120) -> must pass even though a's config (60) exists. + // (A behavioral check: if a's 60-char limit leaked into b, this task would fail.) + val result = projectDir.runTask(":b:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":b:detekt")?.outcome) + } + + @Test + fun `extension-level detekt kotlin projectConfig override applies to all modules`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + kotlin = PlatformDetektBlock(projectConfigPath = "config/strict-detekt.yml"), + ), + ), + extraRootFiles = mapOf("config/strict-detekt.yml" to Configs.DETEKT_MAX_LINE_60), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue(result.output.contains("MaxLineLength"), "expected violation from the override config") + } + + @Test + fun `no rules jar configured does not break the build`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + // rulesPluginJars has no default: nothing configured -> nothing added, build is fine + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `configured rules jar makes the kode rule set available to detekt`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_KODE_RULE, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(detekt = kodeRulesJarBlock()), + rulesJar = rulesJar, + ) + + // Config validation accepts the `kode` rule set only when the jar is on detektPlugins. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `kode rule set config without the rules jar fails with the actionable plugin message`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_KODE_RULE, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + // no rulesJar -> the `kode` rule set is unknown to detekt + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue( + result.output.contains("MISSING DEPENDENCY FOR 'kode' RULE SET"), + "expected the plugin's actionable message, not detekt's raw error", + ) + assertTrue(result.output.contains("detekt.kotlin.rules"), "expected the specific slot to be named") + assertFalse( + result.output.contains("Property 'kode' is misspelled or does not exist"), + "the plugin's message should preempt detekt's raw config-validation error", + ) + val configFile = File(projectDir, "a/detekt-kotlin-config.yml").canonicalFile + val expectedMessage = missingKodeRuleSetDependencyMessage("kotlin", configFile) + assertTrue( + result.output.contains(expectedMessage), + "expected the verbatim missingKodeRuleSetDependencyMessage banner in the output", + ) + } + + @Test + fun `android module using the bundled default config succeeds via the bundled kode rules jar default`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.AndroidLib, + // no detektAndroidConfigContent override -> uses the bundled + // default.android-config.yml, which activates `kode:` via + // RouteWiringMethodNaming. Zero-config: detekt.android.rules now has + // a bundled default (the plugin's own kode rules jar resource), so + // this must succeed with no qualityConfig at all. + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `android rules useDefaults false without a replacement fails with the actionable plugin message`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.AndroidLib, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + android = PlatformDetektBlock(rules = DependencySlot(useDefaults = false)), + ), + ), + ) + + // Bundled android config still activates `kode:`, but the default that would satisfy + // it was explicitly disabled and nothing else was wired — a deliberate opt-out. + val result = projectDir.runTaskWithFail(":a:detekt") + + assertTrue( + result.output.contains("MISSING DEPENDENCY FOR 'kode' RULE SET"), + "expected the plugin's actionable message when the android default is disabled", + ) + assertTrue(result.output.contains("detekt.android.rules"), "expected the android slot to be named") + } + + @Test + fun `android rules useDefaults false with an explicit jar still succeeds`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.AndroidLib, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + android = + PlatformDetektBlock( + rules = + DependencySlot( + files = listOf("libs/detekt-rules-1.4.0.jar"), + useDefaults = false, + ), + ), + ), + ), + rulesJar = rulesJar, + ) + + // The bundled default is off, but the user-supplied jar fully replaces it. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `kotlin module using the bundled default config never triggers the kode rule set check`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + // no detektKotlinConfigContent override -> uses the bundled + // default.kotlin-config.yml, whose only "kode" substring is an + // unrelated forbidden-import value (ru.kode.remo.ReactiveModel) — + // must not false-positive against the anchored `^kode:` check. + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertFalse(result.output.contains("MISSING DEPENDENCY FOR 'kode' RULE SET")) + } + + @Test + fun `detekt sources include restricts check to only the matching sources`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80, + "src/main/kotlin/ru/kode/legacy/Long.kt" to Sources.LONG_LINE_80, + ), + ), + ), + qualityConfig = + QualityConfig( + detekt = DetektBlock(sources = SourcePatternsSlot(include = listOf("**/test/**"))), + ), + ) + + // Only ru/kode/test/Long.kt matches the include pattern — the violating + // ru/kode/legacy/Long.kt is never analyzed, so only the matched file's violation fires. + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue(result.output.contains("Long.kt"), "expected a violation from the included source") + } + + @Test + fun `detekt sources exclude excludes matching sources from check`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE, + "src/main/kotlin/ru/kode/legacy/Long.kt" to Sources.LONG_LINE_80, + ), + ), + ), + qualityConfig = + QualityConfig( + detekt = DetektBlock(sources = SourcePatternsSlot(exclude = listOf("**/legacy/**"))), + ), + ) + + // The only violating source is excluded from analysis entirely. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `detekt sources useDefaults false drops the bundled Kotlin globs`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + qualityConfig = + QualityConfig( + detekt = DetektBlock(sources = SourcePatternsSlot(useDefaults = false)), + ), + ) + + // useDefaults=false with no custom include leaves nothing matched — the task has no + // source at all, so it is skipped rather than run against the violating source. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.NO_SOURCE, result.task(":a:detekt")?.outcome) + } + + @Test + fun `missing detekt-formatting alias in version catalog still succeeds via the baked-in default`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + libsCatalog = LibsCatalog(includeDetektFormatting = false), + ) + + // No `detekt-formatting` alias anywhere in the catalog — the plugin falls back to its + // own baked-in coordinate instead of failing. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `enabling typeResolution invalidates the detekt task's up-to-date state`() { + val module = + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ) + projectDir.createQualityProject(modules = listOf(module)) + val first = projectDir.runTask(":a:detekt") + assertEquals(TaskOutcome.SUCCESS, first.task(":a:detekt")?.outcome) + + projectDir.createQualityProject( + modules = listOf(module), + qualityConfig = QualityConfig(detekt = DetektBlock(typeResolution = true)), + ) + val second = projectDir.runTask(":a:detekt") + + assertTrue( + second.task(":a:detekt")?.outcome != TaskOutcome.UP_TO_DATE, + "enabling typeResolution must wire the compile task's classpath onto detekt and invalidate " + + "its cached result, got ${second.task(":a:detekt")?.outcome}", + ) + } + + @Test + fun `typeResolution false misses a violation that requires resolved types`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_UNNECESSARY_SAFE_CALL, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.UNNECESSARY_SAFE_CALL), + ), + ), + // typeResolution defaults to false: detekt has no classpath and cannot resolve + // that `value` is a non-null String, so UnnecessarySafeCall cannot fire. + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `typeResolution true catches a violation that requires resolved types`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_UNNECESSARY_SAFE_CALL, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.UNNECESSARY_SAFE_CALL), + ), + ), + qualityConfig = QualityConfig(detekt = DetektBlock(typeResolution = true)), + ) + + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue( + result.output.contains("UnnecessarySafeCall"), + "expected UnnecessarySafeCall to fire once type resolution is enabled, got: ${result.output}", + ) + } + + @Test + fun `compose rules useDefaults false without a replacement fails config validation`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + applyComposePlugin = true, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + compose = PlatformDetektBlock(rules = DependencySlot(useDefaults = false)), + ), + ), + ) + + // Bundled compose config activates `compose:`, but the default rules-compose jar that + // would satisfy it was explicitly disabled and nothing else was wired. + val result = projectDir.runTaskWithFail(":a:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":a:detekt")?.outcome) + assertTrue(result.output.contains("compose"), "expected a validation error naming the compose rule set") + } + + @Test + fun `compose rules useDefaults false with an explicit coordinate still succeeds`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + applyComposePlugin = true, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + compose = + PlatformDetektBlock( + rules = + DependencySlot( + notations = listOf("ru.kode:detekt-rules-compose:1.4.0"), + useDefaults = false, + ), + ), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `detekt sources exclude still applies when sources useDefaults is false`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE, + "src/main/kotlin/ru/kode/test/legacy/Long.kt" to Sources.LONG_LINE_80, + ), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + sources = + SourcePatternsSlot( + useDefaults = false, + include = listOf("**/test/**"), + exclude = listOf("**/legacy/**"), + ), + ), + ), + ) + + // legacy/Long.kt matches the custom include ("**/test/**") but excludes are unconditional + // (applied regardless of useDefaults) — it must still be filtered out. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `kode jar wired only via kotlin rules still satisfies the android platform of the same module`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.AndroidLib, + // no override -> bundled default.android-config.yml activates `kode:` + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + // android's own bundled kode default is disabled... + android = PlatformDetektBlock(rules = DependencySlot(useDefaults = false)), + // ...but the kotlin platform (also configured for an AndroidLib + // module) wires the same jar into the shared detektPlugins config. + kotlin = + PlatformDetektBlock( + rules = DependencySlot(files = listOf("libs/detekt-rules-1.4.0.jar")), + ), + ), + ), + rulesJar = rulesJar, + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `verboseLogging defaults to false and suppresses detekt debug output`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertFalse( + result.output.contains("Phase LoadConfig took"), + "verboseLogging defaults to false; detekt's debug phase timings must not appear", + ) + } + + @Test + fun `verboseLogging true enables detekt debug output`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(verboseLogging = true), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertTrue( + result.output.contains("Phase LoadConfig took"), + "verboseLogging=true must enable detekt's debug phase timings", + ) + } + + @Test + fun `changing jvmTarget invalidates the detekt task's up-to-date state`() { + val module = + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ) + projectDir.createQualityProject( + modules = listOf(module), + qualityConfig = QualityConfig(jvmTarget = "JVM_17"), + ) + val first = projectDir.runTask(":a:detekt") + assertEquals(TaskOutcome.SUCCESS, first.task(":a:detekt")?.outcome) + + projectDir.createQualityProject( + modules = listOf(module), + qualityConfig = QualityConfig(jvmTarget = "JVM_11"), + ) + val second = projectDir.runTask(":a:detekt") + + assertTrue( + second.task(":a:detekt")?.outcome != TaskOutcome.UP_TO_DATE, + "changing jvmTarget must invalidate detekt's cached result, got ${second.task(":a:detekt")?.outcome}", + ) + } + + @Test + fun `xml and sarif reports are off by default`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertTrue( + !File(projectDir, "a/build/reports/detekt/detekt.xml").exists(), + "expected no XML report when xmlReportEnabled is left at its default", + ) + assertTrue( + !File(projectDir, "a/build/reports/detekt/detekt.sarif").exists(), + "expected no SARIF report when sarifReportEnabled is left at its default", + ) + } + + @Test + fun `xmlReportEnabled writes the detekt XML report`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(extraExtensionContent = "detekt.xmlReportEnabled.set(true)"), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertTrue( + File(projectDir, "a/build/reports/detekt/detekt.xml").exists(), + "expected an XML report once xmlReportEnabled is set", + ) + } + + @Test + fun `sarifReportEnabled writes the detekt SARIF report`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = QualityConfig(extraExtensionContent = "detekt.sarifReportEnabled.set(true)"), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + assertTrue( + File(projectDir, "a/build/reports/detekt/detekt.sarif").exists(), + "expected a SARIF report once sarifReportEnabled is set", + ) + } + + @Test + fun `baseline suppresses a pre-existing finding so detekt succeeds`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + qualityConfig = + QualityConfig( + extraExtensionContent = + "detekt.baseline.set(rootProject.layout.projectDirectory.file(\"detekt-baseline.xml\"))", + ), + ) + + // Without a baseline, this violation fails the build (confirms the fixture is valid). + val withoutBaseline = projectDir.runTaskWithFail(":a:detekt") + assertEquals(TaskOutcome.FAILED, withoutBaseline.task(":a:detekt")?.outcome) + + val baselineResult = projectDir.runTask(":a:detektBaseline") + assertEquals(TaskOutcome.SUCCESS, baselineResult.task(":a:detektBaseline")?.outcome) + assertTrue( + File(projectDir, "detekt-baseline.xml").exists(), + "expected detektBaseline to write the baseline at the configured path", + ) + + val withBaseline = projectDir.runTask(":a:detekt") + assertEquals(TaskOutcome.SUCCESS, withBaseline.task(":a:detekt")?.outcome) + } + + @Test + fun `detekt is not applied to a plain java module`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "javaonly", + type = ModuleType.JavaOnly, + javaSources = + mapOf( + "src/main/java/ru/kode/test/Main.java" to + "package ru.kode.test;\n\npublic class Main {}\n", + ), + ), + ), + ) + + val result = projectDir.runTasks(":javaonly:tasks", arguments = listOf("--all")) + + assertFalse( + result.output.contains("detekt "), + "plain java module must not get detekt tasks", + ) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ExampleTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ExampleTest.kt deleted file mode 100644 index 5dd8947..0000000 --- a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/ExampleTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ru.kode.android.app.quality.plugin.foundation - -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import ru.kode.android.app.quality.plugin.test.utils.BuildType -import ru.kode.android.app.quality.plugin.test.utils.FoundationConfig -import ru.kode.android.app.quality.plugin.test.utils.createAndroidProject -import java.io.File -import java.io.IOException - -class ExampleTest { - @TempDir - lateinit var tempDir: File - private lateinit var projectDir: File - - @BeforeEach - fun setup() { - projectDir = File(tempDir, "test-project") - } - - @Test - @Throws(IOException::class) - fun `bundle creates renamed file of debug build from one tag, one commit, build types only`() { - projectDir.createAndroidProject( - buildTypes = listOf(BuildType("debug"), BuildType("release")), - foundationConfig = - FoundationConfig( - output = - FoundationConfig.Output( - baseFileName = "autotest", - ), - ), - ) - - } -} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/GitHooksTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/GitHooksTest.kt new file mode 100644 index 0000000..1c28b6c --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/GitHooksTest.kt @@ -0,0 +1,109 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.initGit +import ru.kode.android.app.quality.plugin.test.utils.runTask +import java.io.File + +class GitHooksTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + private fun hooksPathFromGitConfig(): String? { + return File(projectDir, ".git/config") + .readLines() + .firstOrNull { it.trim().startsWith("hooksPath") } + ?.substringAfter("=") + ?.trim() + } + + @Test + fun `custom gitHooks path is written to git config`() { + projectDir.createQualityProject( + modules = listOf(ModuleSpec(name = "a", type = ModuleType.KotlinJvm)), + qualityConfig = QualityConfig(gitHooksPath = ".myhooks"), + extraRootFiles = mapOf(".myhooks/.keep" to ""), + ) + projectDir.initGit() + + val result = projectDir.runTask("gitHooksSetup") + + assertEquals(TaskOutcome.SUCCESS, result.task(":gitHooksSetup")?.outcome) + val hooksPath = hooksPathFromGitConfig() + assertTrue( + hooksPath != null && hooksPath.endsWith(".myhooks"), + "expected hooksPath ending with .myhooks, got: $hooksPath", + ) + } + + @Test + fun `default gitHooks path is rootProject githooks directory`() { + projectDir.createQualityProject( + modules = listOf(ModuleSpec(name = "a", type = ModuleType.KotlinJvm)), + ) + projectDir.initGit() + + val result = projectDir.runTask("gitHooksSetup") + + assertEquals(TaskOutcome.SUCCESS, result.task(":gitHooksSetup")?.outcome) + val hooksPath = hooksPathFromGitConfig() + assertTrue( + hooksPath != null && hooksPath.endsWith(".githooks"), + "expected hooksPath ending with .githooks, got: $hooksPath", + ) + } + + @Test + fun `gitHooksSetup is skipped when project is not a git repo`() { + projectDir.createQualityProject( + modules = listOf(ModuleSpec(name = "a", type = ModuleType.KotlinJvm)), + ) + // No initGit() call — project has no .git directory. + + val result = projectDir.runTask("gitHooksSetup") + + assertEquals(TaskOutcome.SKIPPED, result.task(":gitHooksSetup")?.outcome) + } + + @Test + fun `gitHooksSetup is skipped when gitHooksEnabled is false`() { + projectDir.createQualityProject( + modules = listOf(ModuleSpec(name = "a", type = ModuleType.KotlinJvm)), + qualityConfig = QualityConfig(gitHooksEnabled = false), + ) + projectDir.initGit() + + val result = projectDir.runTask("gitHooksSetup") + + assertEquals(TaskOutcome.SKIPPED, result.task(":gitHooksSetup")?.outcome) + } + + @Test + fun `prePushCheck succeeds without git hooks setup when gitHooksEnabled is false`() { + projectDir.createQualityProject( + modules = listOf(ModuleSpec(name = "a", type = ModuleType.KotlinJvm)), + qualityConfig = QualityConfig(gitHooksEnabled = false), + ) + // No initGit() call and no .githooks directory — would fail git config if not skipped. + + val result = projectDir.runTask("prePushCheck") + + assertEquals(TaskOutcome.SKIPPED, result.task(":gitHooksSetup")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":prePushCheck")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/JetbrainsComposeConfigurationTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/JetbrainsComposeConfigurationTest.kt new file mode 100644 index 0000000..2218a2f --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/JetbrainsComposeConfigurationTest.kt @@ -0,0 +1,87 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.PlatformDetektBlock +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.runTask +import java.io.File + +/** + * `org.jetbrains.compose` (JetBrains Compose Multiplatform) is one of the two COMPOSE-platform + * trigger plugin IDs the plugin recognizes (the other is `org.jetbrains.kotlin.plugin.compose`, + * exercised by the applyComposePlugin=true tests elsewhere). This verifies it configures the + * exact same compose detekt layer as its sibling. + */ +class JetbrainsComposeConfigurationTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `org-jetbrains-compose triggers the compose detekt layer via the baked-in default`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + applyJetbrainsComposePlugin = true, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + // No detekt.compose.rules configured — the compose config's `compose:` rule set only + // validates if the plugin's bundled default wires detekt-compose-rules automatically. + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } + + @Test + fun `org-jetbrains-compose module accepts an explicit compose rules coordinate`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + applyJetbrainsComposePlugin = true, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + detekt = + DetektBlock( + compose = + PlatformDetektBlock( + rules = + DependencySlot( + notations = listOf("ru.kode:detekt-rules-compose:1.4.0"), + useDefaults = false, + ), + ), + ), + ), + ) + + val result = projectDir.runTask(":a:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":a:detekt")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinDslConsumerTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinDslConsumerTest.kt new file mode 100644 index 0000000..8b20f31 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinDslConsumerTest.kt @@ -0,0 +1,62 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.KtlintBlock +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.SourcePatternsSlot +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.initGit +import ru.kode.android.app.quality.plugin.test.utils.runTask +import java.io.File + +/** + * Groovy scripts only ever hit the extension's Closure overloads. A real `.gradle.kts` + * consumer exercises the `Action` overloads instead — this is the only test in the suite + * that generates and runs real Kotlin-DSL build scripts. + */ +class KotlinDslConsumerTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `Kotlin DSL consumer project passes pipelineCheck end to end`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + verboseLogging = true, + ktlint = KtlintBlock(sources = SourcePatternsSlot(include = listOf("**/test/**"))), + ), + useKotlinDsl = true, + ) + projectDir.initGit() + + assertTrue(File(projectDir, "settings.gradle.kts").exists(), "expected a settings.gradle.kts file") + assertTrue(File(projectDir, "build.gradle.kts").exists(), "expected a root build.gradle.kts file") + assertTrue(File(projectDir, "a/build.gradle.kts").exists(), "expected the module's build.gradle.kts file") + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinMultiplatformConfigurationTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinMultiplatformConfigurationTest.kt new file mode 100644 index 0000000..8697da7 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KotlinMultiplatformConfigurationTest.kt @@ -0,0 +1,70 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import java.io.File + +/** + * Covers `org.jetbrains.kotlin.multiplatform` as a Kotlin detekt-platform trigger — a module + * applying it (instead of `org.jetbrains.kotlin.jvm`) must still get the plugin's Kotlin + * detekt layer configured. + */ +class KotlinMultiplatformConfigurationTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `kotlin multiplatform module gets the Kotlin detekt layer configured`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "shared", + type = ModuleType.KotlinJvm, + applyMultiplatformPlugin = true, + detektKotlinConfigContent = Configs.DETEKT_MAX_LINE_60, + kotlinSources = mapOf("src/jvmMain/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + ) + + val result = projectDir.runTaskWithFail(":shared:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":shared:detekt")?.outcome) + assertTrue(result.output.contains("MaxLineLength"), "expected the module's detekt config to be applied") + } + + @Test + fun `kotlin multiplatform module passes with the bundled default config`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "shared", + type = ModuleType.KotlinJvm, + applyMultiplatformPlugin = true, + kotlinSources = mapOf("src/jvmMain/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask(":shared:detekt") + + assertEquals(TaskOutcome.SUCCESS, result.task(":shared:detekt")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintConfigurationTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintConfigurationTest.kt new file mode 100644 index 0000000..bc8a996 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/KtlintConfigurationTest.kt @@ -0,0 +1,353 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.KtlintBlock +import ru.kode.android.app.quality.plugin.test.utils.LibsCatalog +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.SourcePatternsSlot +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import java.io.File + +class KtlintConfigurationTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + @Test + fun `custom projectConfig editorconfig is used - 4-space source passes under indent_size 4`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(projectConfigPath = "config/custom.editorconfig"), + ), + extraRootFiles = mapOf("config/custom.editorconfig" to Configs.EDITORCONFIG_INDENT_4), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `custom projectConfig editorconfig is used - 2-space source fails under indent_size 4`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(projectConfigPath = "config/custom.editorconfig"), + ), + extraRootFiles = mapOf("config/custom.editorconfig" to Configs.EDITORCONFIG_INDENT_4), + ) + + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertEquals(TaskOutcome.FAILED, result.task(":ktlintCheck")?.outcome) + assertTrue(result.output.contains("standard:indent"), "expected an indent violation in output") + } + + @Test + fun `root editorconfig is used when no override is configured`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + rootEditorConfigContent = Configs.EDITORCONFIG_INDENT_4, + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `bundled default editorconfig (indent 2) is materialized and used when no file exists`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `bundled default editorconfig rejects 4-space source`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + ) + + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertEquals(TaskOutcome.FAILED, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `sources exclude excludes matching sources from check`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE, + "src/main/kotlin/ru/kode/legacy/Legacy.kt" to Sources.CLEAN_FOUR_SPACE, + ), + ), + ), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(sources = SourcePatternsSlot(exclude = listOf("**/legacy/**"))), + ), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `ktlintFormat fixes malformatted source so that ktlintCheck passes afterwards`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + ) + + val formatResult = projectDir.runTask("ktlintFormat") + assertEquals(TaskOutcome.SUCCESS, formatResult.task(":ktlintFormat")?.outcome) + + val formatted = File(projectDir, "a/src/main/kotlin/ru/kode/test/Main.kt").readText() + assertTrue(formatted.contains("\n println"), "expected source reformatted to 2-space indent") + + val checkResult = projectDir.runTask("ktlintCheck") + assertEquals(TaskOutcome.SUCCESS, checkResult.task(":ktlintCheck")?.outcome) + } + + @Test + fun `ktlintFormat is up-to-date on a second run with no source changes`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + ) + + val firstRun = projectDir.runTask("ktlintFormat") + assertEquals(TaskOutcome.SUCCESS, firstRun.task(":ktlintFormat")?.outcome) + + val secondRun = projectDir.runTask("ktlintFormat") + assertEquals(TaskOutcome.UP_TO_DATE, secondRun.task(":ktlintFormat")?.outcome) + } + + @Test + fun `ktlintFormat reruns after the source it formatted changes again`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + ) + + val firstRun = projectDir.runTask("ktlintFormat") + assertEquals(TaskOutcome.SUCCESS, firstRun.task(":ktlintFormat")?.outcome) + + // Write content that wasn't seen by the first run (not merely reverting to the + // pre-format bytes) so the change is visible to ktlintFormat's input snapshot. + val sourceFile = File(projectDir, "a/src/main/kotlin/ru/kode/test/Main.kt") + sourceFile.writeText(Sources.CLEAN_FOUR_SPACE.replace("\"ok\"", "\"ok again\"")) + + val secondRun = projectDir.runTask("ktlintFormat") + assertEquals(TaskOutcome.SUCCESS, secondRun.task(":ktlintFormat")?.outcome) + } + + @Test + fun `sources include restricts check to only the matching sources`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE, + "src/main/kotlin/ru/kode/legacy/Legacy.kt" to Sources.CLEAN_FOUR_SPACE, + ), + ), + ), + qualityConfig = + QualityConfig( + ktlint = + KtlintBlock( + sources = SourcePatternsSlot(useDefaults = false, include = listOf("**/test/**")), + ), + ), + ) + + // Only src/main/kotlin/ru/kode/test/Main.kt matches the include pattern — the + // malformatted Legacy.kt under ru/kode/legacy is never checked. + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `sources useDefaults false drops the bundled Kotlin globs`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_FOUR_SPACE), + ), + ), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(sources = SourcePatternsSlot(useDefaults = false)), + ), + ) + + // useDefaults=false with no custom include leaves nothing matched — the malformatted + // 4-space source is never checked, so the task succeeds despite the violation. + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `sources include exclude vararg sugar works the same as the list form`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = + mapOf( + "src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE, + "src/main/kotlin/ru/kode/legacy/Legacy.kt" to Sources.CLEAN_FOUR_SPACE, + ), + ), + ), + qualityConfig = + QualityConfig( + extraExtensionContent = + "ktlint.sources { useDefaults.set(false); include(\"**/test/**\", \"**/other/**\") }", + ), + ) + + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `ktlint cli useDefaults false without a replacement fails to resolve`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + qualityConfig = + QualityConfig( + ktlint = KtlintBlock(cli = DependencySlot(useDefaults = false)), + ), + ) + + // No default CLI dependency and nothing else wired -> the ktlint CLI classpath is + // empty and the check task cannot run at all. + val result = projectDir.runTaskWithFail("ktlintCheck") + + assertEquals(TaskOutcome.FAILED, result.task(":ktlintCheck")?.outcome) + } + + @Test + fun `missing ktlint-cli alias in version catalog still succeeds via the baked-in default`() { + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "a", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ), + libsCatalog = LibsCatalog(includeKtlintCli = false), + ) + + // No `ktlint-cli` alias anywhere in the catalog — the plugin falls back to its own + // baked-in coordinate instead of failing. + val result = projectDir.runTask("ktlintCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintCheck")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/RealProjectShapeTest.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/RealProjectShapeTest.kt new file mode 100644 index 0000000..a62dbcb --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/RealProjectShapeTest.kt @@ -0,0 +1,212 @@ +package ru.kode.android.app.quality.plugin.foundation + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.KtlintBlock +import ru.kode.android.app.quality.plugin.test.utils.LibsCatalog +import ru.kode.android.app.quality.plugin.test.utils.ModuleSpec +import ru.kode.android.app.quality.plugin.test.utils.ModuleType +import ru.kode.android.app.quality.plugin.test.utils.PlatformDetektBlock +import ru.kode.android.app.quality.plugin.test.utils.QualityConfig +import ru.kode.android.app.quality.plugin.test.utils.createQualityProject +import ru.kode.android.app.quality.plugin.test.utils.initGit +import ru.kode.android.app.quality.plugin.test.utils.runTask +import ru.kode.android.app.quality.plugin.test.utils.runTaskWithFail +import java.io.File + +/** + * Mirrors how the plugin is integrated in real KODE projects (loot & co): multi-module + * android app + compose ui + pure-kotlin domain, extra build types, root .editorconfig, + * `libs` catalog with all aliases, and the custom rules jar configured explicitly. + */ +class RealProjectShapeTest { + @TempDir + lateinit var tempDir: File + private lateinit var projectDir: File + + @BeforeEach + fun setup() { + projectDir = File(tempDir, "test-project") + } + + private fun lootShapedModules() = + listOf( + ModuleSpec( + name = "app", + type = ModuleType.AndroidApp, + buildTypes = listOf("internal", "demo"), + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Main.kt" to Sources.CLEAN_TWO_SPACE), + ), + ModuleSpec( + name = "feature-ui", + type = ModuleType.AndroidLib, + applyComposePlugin = true, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Ui.kt" to Sources.CLEAN_TWO_SPACE), + ), + ModuleSpec( + name = "feature-domain", + type = ModuleType.KotlinJvm, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Domain.kt" to Sources.CLEAN_TWO_SPACE), + ), + ) + + private fun lootShapedConfig() = QualityConfig(detekt = kodeRulesJarBlock()) + + @Test + fun `loot-shaped project passes pipelineCheck with compose layer end to end`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = lootShapedModules(), + qualityConfig = lootShapedConfig(), + rootEditorConfigContent = Configs.EDITORCONFIG_INDENT_2, + rulesJar = rulesJar, + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + // Every module's detekt ran: compose layer validated on feature-ui (the bundled + // compose config's `compose:` rule set only loads with detekt-compose-rules resolved). + listOf(":app", ":feature-ui", ":feature-domain").forEach { module -> + assertTrue( + result.tasks.map { it.path }.any { it.startsWith("$module:detekt") }, + "expected detekt tasks for $module", + ) + } + } + + @Test + fun `loot-shaped project passes prePushCheck`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = lootShapedModules(), + qualityConfig = lootShapedConfig(), + rootEditorConfigContent = Configs.EDITORCONFIG_INDENT_2, + rulesJar = rulesJar, + ) + projectDir.initGit() + + val result = projectDir.runTask("prePushCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":prePushCheck")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":ktlintFormat")?.outcome) + } + + @Test + fun `module-local compose config overrides the bundled compose default`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + // The module config activates a compose rule against which the source violates: + // Modifier parameter not named `modifier` -> ComposeFunctionName? Use MaxLineLength + // inside the compose config instead: any rule works, the assertion is that the + // MODULE file (60-char limit) wins over the bundled default (no such limit). + val strictComposeConfig = + """ + |build: + | maxIssues: 0 + | + |style: + | MaxLineLength: + | active: true + | maxLineLength: 60 + | + """.trimMargin() + + projectDir.createQualityProject( + modules = + listOf( + ModuleSpec( + name = "feature-ui", + type = ModuleType.KotlinJvm, + applyComposePlugin = true, + detektComposeConfigContent = strictComposeConfig, + kotlinSources = mapOf("src/main/kotlin/ru/kode/test/Long.kt" to Sources.LONG_LINE_80), + ), + ), + qualityConfig = lootShapedConfig(), + rulesJar = rulesJar, + ) + + val result = projectDir.runTaskWithFail(":feature-ui:detekt") + + assertEquals(TaskOutcome.FAILED, result.task(":feature-ui:detekt")?.outcome) + assertTrue( + result.output.contains("MaxLineLength"), + "expected the module compose config (60-char limit) to be applied", + ) + } + + @Test + fun `loot-shaped project works with typed accessors from a custom catalog`() { + val rulesJar = exampleRulesJar() + assumeTrue(rulesJar != null, "example-project rules jar not found; skipping") + + projectDir.createQualityProject( + modules = lootShapedModules(), + qualityConfig = + QualityConfig( + ktlint = + KtlintBlock( + cli = DependencySlot(refs = listOf("deps.ktlint.cli"), useDefaults = false), + ), + detekt = + DetektBlock( + kotlin = + PlatformDetektBlock( + rules = + DependencySlot( + refs = listOf("deps.detekt.formatting"), + files = listOf("libs/detekt-rules-1.4.0.jar"), + useDefaults = false, + ), + ), + compose = + PlatformDetektBlock( + rules = + DependencySlot( + refs = listOf("deps.detekt.compose.rules"), + useDefaults = false, + ), + ), + ), + ), + rootEditorConfigContent = Configs.EDITORCONFIG_INDENT_2, + rulesJar = rulesJar, + libsCatalog = LibsCatalog(name = "deps"), + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + } + + @Test + fun `zero-config real production shape passes pipelineCheck with only verboseLogging set`() { + // Mirrors what all 3 real adopters (ceb-mobile, esim-android, loot-android) actually do: + // apply the plugin once at root and configure nothing but verboseLogging. No rules jar, + // no editorconfig override, no custom sources/dependency slots — bundled defaults only. + projectDir.createQualityProject( + modules = lootShapedModules(), + qualityConfig = QualityConfig(verboseLogging = false), + ) + projectDir.initGit() + + val result = projectDir.runTask("pipelineCheck") + + assertEquals(TaskOutcome.SUCCESS, result.task(":pipelineCheck")?.outcome) + } +} diff --git a/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/TestFixtures.kt b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/TestFixtures.kt new file mode 100644 index 0000000..43f4291 --- /dev/null +++ b/plugin-test/foundation/src/test/kotlin/ru/kode/android/app/quality/plugin/foundation/TestFixtures.kt @@ -0,0 +1,189 @@ +package ru.kode.android.app.quality.plugin.foundation + +import ru.kode.android.app.quality.plugin.test.utils.DependencySlot +import ru.kode.android.app.quality.plugin.test.utils.DetektBlock +import ru.kode.android.app.quality.plugin.test.utils.PlatformDetektBlock +import java.io.File + +/** + * Kotlin sources with known quality-rule behavior, shared across the suite. + */ +object Sources { + /** Clean under indent_size=2 editorconfig and under the bundled default detekt config. */ + val CLEAN_TWO_SPACE = + """ + |package ru.kode.test + | + |fun main() { + | println("ok") + |} + | + """.trimMargin() + + /** Clean under indent_size=4 editorconfig, violates indent under indent_size=2. */ + val CLEAN_FOUR_SPACE = + """ + |package ru.kode.test + | + |fun main() { + | println("ok") + |} + | + """.trimMargin() + + /** + * Contains one 80-char comment line: passes MaxLineLength=120 (bundled default), + * fails MaxLineLength=60 (custom test config). A comment triggers no other rules. + */ + val LONG_LINE_80 = + """ + |package ru.kode.test + | + |// ${"x".repeat(77)} + | + |fun main() { + | println("ok") + |} + | + """.trimMargin() + + /** + * `value?.length` on a non-null `String` param: syntactically legal Kotlin (a compiler + * warning, not an error), but only detectable as an UNNECESSARY safe call once the + * analyzer knows `value` is non-null — which requires type resolution. + */ + val UNNECESSARY_SAFE_CALL = + """ + |package ru.kode.test + | + |fun printLength(value: String) { + | println(value?.length) + |} + | + """.trimMargin() +} + +object Configs { + val EDITORCONFIG_INDENT_2 = + """ + |root = true + | + |[*.{kt,kts}] + |indent_style = space + |indent_size = 2 + |max_line_length = 120 + |insert_final_newline = true + | + """.trimMargin() + + val EDITORCONFIG_INDENT_4 = + """ + |root = true + | + |[*.{kt,kts}] + |indent_style = space + |indent_size = 4 + |max_line_length = 120 + |insert_final_newline = true + | + """.trimMargin() + + /** Complete-enough detekt config: any line longer than 60 chars fails. */ + val DETEKT_MAX_LINE_60 = + """ + |build: + | maxIssues: 0 + | + |style: + | MaxLineLength: + | active: true + | maxLineLength: 60 + | + """.trimMargin() + + /** + * Activates rules from BOTH the custom KODE jar and the default detekt-formatting + * dependency: config validation only accepts each section when the matching artifact is + * on detektPlugins, and the 2-space Indentation rule fires on 4-space sources. + */ + val DETEKT_KODE_AND_FORMATTING = + """ + |build: + | maxIssues: 0 + | + |formatting: + | Indentation: + | active: true + | indentSize: 2 + | + |kode: + | ImmutableDataClass: + | active: true + | + """.trimMargin() + + /** + * Activates a rule from the custom KODE rules jar: detekt only accepts this config when + * the jar is on the detektPlugins classpath (unknown rule set fails config validation). + */ + val DETEKT_KODE_RULE = + """ + |build: + | maxIssues: 0 + | + |kode: + | ImmutableDataClass: + | active: true + | + """.trimMargin() + + /** + * `UnnecessarySafeCall` (potential-bugs) only fires with type resolution enabled: without + * it, detekt cannot know whether the receiver is actually non-null. + */ + val DETEKT_UNNECESSARY_SAFE_CALL = + """ + |build: + | maxIssues: 0 + | + |potential-bugs: + | UnnecessarySafeCall: + | active: true + | + """.trimMargin() +} + +/** + * The standard test configuration for the KODE rules jar copied by `rulesJar` into + * `/libs/detekt-rules-1.4.0.jar` — since the plugin has no jar convention anymore, + * every project whose android config activates `kode:` rules must configure it explicitly. + */ +fun kodeRulesJarBlock(): DetektBlock = + DetektBlock( + kotlin = + PlatformDetektBlock( + // Stacks ON TOP of the default detekt-formatting — the loot semantics. + rules = DependencySlot(files = listOf("libs/detekt-rules-1.4.0.jar")), + ), + ) + +/** + * The custom KODE detekt rules jar shipped in the repo's example project; used to test + * rulesPluginJar wiring. Resolved relative to the plugin-test/foundation working dir. + */ +fun exampleRulesJar(): File? { + val candidates = + listOf( + File("../../example-project/libs/detekt-rules-1.4.0.jar"), + File(System.getProperty("user.dir"), "../../example-project/libs/detekt-rules-1.4.0.jar"), + ) + return candidates.map { it.canonicalFile }.firstOrNull { it.isFile } +} + +/** + * Pre-built-in-Kotlin toolchain used to test variant detekt tasks (detektDebug/detektRelease): + * detekt 1.x registers them only for the classic org.jetbrains.kotlin.android setup. + * KGP 2.2.10 on the test classpath supports AGP up to 8.x; AGP 8.7.3 requires Gradle 8.9+. + */ +const val LEGACY_AGP_VERSION = "8.7.3" +const val LEGACY_GRADLE_VERSION = "8.14" diff --git a/plugin-test/settings.gradle.kts b/plugin-test/settings.gradle.kts index 8cc61ff..354b128 100644 --- a/plugin-test/settings.gradle.kts +++ b/plugin-test/settings.gradle.kts @@ -26,4 +26,5 @@ rootProject.name = ("ru.kode.android.app.quality.plugin-test") include("foundation") include("utils") includeBuild("../build-conventions") +includeBuild("../plugin-build") diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AlwaysInfoLogger.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AlwaysInfoLogger.kt index 0e71528..461c783 100644 --- a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AlwaysInfoLogger.kt +++ b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AlwaysInfoLogger.kt @@ -12,7 +12,10 @@ class AlwaysInfoLogger : Logger by Logging.getLogger("AppQualityTest") { println("[WARN] $message") } - override fun error(message: String?, exception: Throwable?) { + override fun error( + message: String?, + exception: Throwable?, + ) { println("[ERROR] $message, ${exception?.message}") } diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AndroidProjectBuilders.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AndroidProjectBuilders.kt deleted file mode 100644 index a51d895..0000000 --- a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/AndroidProjectBuilders.kt +++ /dev/null @@ -1,1054 +0,0 @@ -package ru.kode.android.app.quality.plugin.test.utils - -import org.gradle.testfixtures.ProjectBuilder -import org.gradle.testkit.runner.BuildResult -import org.gradle.testkit.runner.GradleRunner -import org.gradle.testkit.runner.internal.PluginUnderTestMetadataReading -import java.io.BufferedWriter -import java.io.File -import java.io.FileWriter -import java.io.IOException - -private val IS_CI get() = System.getenv("CI") == "true" - -@Suppress("LongMethod", "CyclomaticComplexMethod", "CascadingCallWrapping") -fun File.createAndroidProject( - compileSdk: Int = 36, - buildTypes: List, - productFlavors: List = listOf(), - defaultConfig: DefaultConfig? = DefaultConfig(), - foundationConfig: FoundationConfig = FoundationConfig(), - clickUpConfig: ClickUpConfig? = null, - confluenceConfig: ConfluenceConfig? = null, - firebaseConfig: FirebaseConfig? = null, - jiraConfig: JiraConfig? = null, - playConfig: PlayConfig? = null, - slackConfig: SlackConfig? = null, - telegramConfig: TelegramConfig? = null, - topBuildFileContent: String? = null, - import: String? = null, - configureApplicationVariants: Boolean = false, -) { - val topSettingsFile = this.getFile("settings.gradle") - val topBuildFile = this.getFile("build.gradle") - val appBuildFile = this.getFile("app/build.gradle") - val androidManifestFile = this.getFile("app/src/main/AndroidManifest.xml") - - if (topBuildFileContent != null) { - println("--- ${topBuildFile.path} START ---") - println(topBuildFileContent) - println("--- ${topBuildFile.path} END ---") - topBuildFile.writeText(topBuildFileContent) - } - - val topSettingsFileContent = - """ - pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } - } - - dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenLocal() - google() - mavenCentral() - } - } - - rootProject.name = "My Application" - include(":app") - """.trimIndent() - .also { - println("--- ${topSettingsFile.path} START ---") - println(it) - println("--- ${topBuildFile.path} END ---") - } - writeFile(topSettingsFile, topSettingsFileContent) - - val fullApplicationId = "\${fullApplicationId}" - val authority = "\${authority}" - val buildTypeBuilder = { type: BuildType -> - val appId = type.appId?.let { appId -> "\"$appId\"" } - ?: """android.defaultConfig.applicationId + ".${type.name}"""" - val suffix = type.applicationIdSuffix?.let { suffix -> "applicationIdSuffix \"$suffix\"" } ?: "" - """ - ${type.name} { - def fullApplicationId = $appId - def authority = "$fullApplicationId.provider" - - debuggable true - $suffix - buildConfigField "String", "FILE_PROVIDER_AUTHORITY", "\"$authority\"" - manifestPlaceholders = [ - APPLICATION_ID : fullApplicationId, - FILE_PROVIDER_AUTHORITY : authority - ] - } - """.takeIf { configureApplicationVariants } ?: type.name - } - val buildTypesBlock = - buildTypes - .joinToString(separator = "\n") { - """ - ${buildTypeBuilder(it)} - """ - } - .let { buildType -> - """ - buildTypes { - $buildType - } - """ - } - val flavorDimensionsBlock = - productFlavors - .takeIf { flavor -> flavor.isNotEmpty() } - ?.mapTo(mutableSetOf()) { flavor -> flavor.dimension } - ?.joinToString { dimension -> "\"$dimension\"" } - ?.let { dimension -> - "flavorDimensions += [$dimension]" - } - .orEmpty() - val productFlavorsBlock = - productFlavors - .takeIf { flavor -> flavor.isNotEmpty() } - ?.joinToString(separator = "\n") { flavor -> - """ - create("${flavor.name}") { - dimension = "${flavor.dimension}" - } - """ - }?.let { flavor -> - """ - productFlavors { - $flavor - } - """ - }.orEmpty() - - val defaultConfigBlock = - defaultConfig?.let { config -> - """ - defaultConfig { - applicationId "${config.applicationId}" - minSdk ${config.minSdk} - targetSdk ${config.targetSdk} - - ${config.versionCode?.let { vsCode -> "versionCode $vsCode" }.orEmpty()} - ${config.versionName?.let { vsName -> "versionName \"$vsName\"" }.orEmpty()} - } - """ - } - val buildTypeOutputBlock1 = - foundationConfig.buildTypeOutput?.let { (name, config) -> - val useVersionsFromTag = config.useVersionsFromTag?.let { use -> - "useVersionsFromTag.set($use)" - }.orEmpty() - val useStabs = config.useStubsForTagAsFallback?.let { use -> - "useStubsForTagAsFallback.set($use)" - }.orEmpty() - val useDefaults = config.useDefaultsForVersionsAsFallback?.let { use -> - "useDefaultsForVersionsAsFallback.set($use)" - }.orEmpty() - val pattern = config.buildTagPatternBuilderFunctions?.let { pattern -> - buildTagPatternBlock(pattern) - }.orEmpty() - val versionName = config.versionNameStrategy?.let { strategy -> - """versionNameStrategy { $strategy }""" - }.orEmpty() - val versionTag = config.versionCodeStrategy?.let { strategy -> - """versionCodeStrategy { $strategy }""" - }.orEmpty() - - """ - buildVariant("$name") { - baseFileName.set("${config.baseFileName}") - $useVersionsFromTag - $useStabs - $useDefaults - $pattern - $versionName - $versionTag - } - """ - } - val buildTypeOutputBlock2 = - foundationConfig.buildTypeOutput2?.let { (name, config) -> - val useVersionsFromTag = config.useVersionsFromTag?.let { use -> - "useVersionsFromTag.set($use)" - }.orEmpty() - val useStubs = config.useStubsForTagAsFallback?.let { use -> - "useStubsForTagAsFallback.set($use)" - }.orEmpty() - val yseDefaults = config.useDefaultsForVersionsAsFallback?.let { use -> - "useDefaultsForVersionsAsFallback.set($use)" - }.orEmpty() - val pattern = config.buildTagPatternBuilderFunctions?.let { pattern -> - buildTagPatternBlock(pattern) - }.orEmpty() - val versionName = config.versionNameStrategy?.let { strategy -> - """versionNameStrategy { $strategy }""" - }.orEmpty() - val versionCode = config.versionCodeStrategy?.let { strategy -> - """versionCodeStrategy { $strategy }""" - }.orEmpty() - """ - buildVariant("$name") { - baseFileName.set("${config.baseFileName}") - $useVersionsFromTag - $useStubs - $yseDefaults - $pattern - $versionName - $versionCode - } - """ - } - val buildTypeOutputBlock3 = - foundationConfig.buildTypeOutput3?.let { (name, config) -> - val useVersions = config.useVersionsFromTag?.let { use -> - "useVersionsFromTag.set($use)" - }.orEmpty() - val useStubs = config.useStubsForTagAsFallback?.let { use -> - "useStubsForTagAsFallback.set($use)" - }.orEmpty() - val useDefaults = config.useDefaultsForVersionsAsFallback?.let { use -> - "useDefaultsForVersionsAsFallback.set($use)" - }.orEmpty() - val pattern = config.buildTagPatternBuilderFunctions?.let { pattern -> - buildTagPatternBlock(pattern) - }.orEmpty() - val versionName = config.versionNameStrategy?.let { strategy -> - """versionNameStrategy { $strategy }""" - }.orEmpty() - val versionCode = config.versionCodeStrategy?.let { strategy -> - """versionCodeStrategy { $strategy }""" - }.orEmpty() - """ - buildVariant("$name") { - baseFileName.set("${config.baseFileName}") - $useVersions - $useStubs - $useDefaults - $pattern - $versionName - $versionCode - } - """ - } - val useVersions = foundationConfig.output.useVersionsFromTag?.let { use -> - "useVersionsFromTag.set($use)" - }.orEmpty() - val useStubs = foundationConfig.output.useStubsForTagAsFallback?.let { use -> - "useStubsForTagAsFallback.set($use)" - }.orEmpty() - val useDefaults = foundationConfig.output.useDefaultsForVersionsAsFallback?.let { use -> - "useDefaultsForVersionsAsFallback.set($use)" - } - val pattern = foundationConfig.output.buildTagPatternBuilderFunctions?.let { pattern -> - buildTagPatternBlock(pattern) - }.orEmpty() - val changelogStrategy = foundationConfig.changelog.changelogMessageStrategy?.let { strategy -> - "changelogMessageStrategy { $strategy }" - }.orEmpty() - val foundationConfigBlock = """ - appQualityFoundation { - bodyLogging.set(${foundationConfig.bodyLogging}) - verboseLogging.set(${foundationConfig.verboseLogging}) - - output { - common { - baseFileName.set("${foundationConfig.output.baseFileName}") - $useVersions - $useStubs - ${useDefaults.orEmpty()} - $pattern - } - - ${buildTypeOutputBlock1.orEmpty()} - ${buildTypeOutputBlock2.orEmpty()} - ${buildTypeOutputBlock3.orEmpty()} - } - - changelogCommon { - issueNumberPattern.set("${foundationConfig.changelog.issueNumberPattern}") - issueUrlPrefix.set("${foundationConfig.changelog.issueUrlPrefix}") - commitMessageKey.set("${foundationConfig.changelog.commitMessageKey}") - $changelogStrategy - } - } - """ - - val clickUpConfigBlock = - clickUpConfig?.let { config -> - val automation = config.automation?.let { automation -> - clickUpAutomationBlock(automation) - }.orEmpty() - """ - appQualityClickUp { - auth { - common { - apiTokenFile = project.file("${config.auth.apiTokenFilePath}") - } - } - - $automation - } - """ - }.orEmpty() - - val confluenceConfigBlock = - confluenceConfig?.let { config -> - val distribution = config.distribution?.let { distribution -> - confluenceDistributionBlock(distribution) - }.orEmpty() - """ - appQualityConfluence { - auth { - common { - baseUrl.set("${config.auth.baseUrl}") - credentials.username.set("${config.auth.username}") - credentials.password.set("${config.auth.password}") - } - } - - $distribution - } - """ - }.orEmpty() - - val firebaseConfigBlock = - firebaseConfig?.let { config -> - val buildTypeFirebaseBlock = - config.distributionBuildType?.let { (name, config) -> - val testerGroups = config.testerGroups?.let { testerGroups -> - """testerGroups(${testerGroups.joinToString { group -> "\"$group\"" }})""" - }.orEmpty() - """ - buildVariant("$name") { - serviceCredentialsFile = project.file("${config.serviceCredentialsFilePath}") - appId.set("${config.appId}") - artifactType.set(${config.artifactType}) - $testerGroups - } - """ - } - val testerGroups = config.distributionCommon.testerGroups?.let { testerGroups -> - """testerGroups(${testerGroups.joinToString { group -> "\"$group\"" }})""" - } - """ - appQualityFirebase { - distribution { - common { - serviceCredentialsFile = project.file("${config.distributionCommon.serviceCredentialsFilePath}") - appId.set("${config.distributionCommon.appId}") - artifactType.set(${config.distributionCommon.artifactType}) - ${testerGroups.orEmpty()} - } - ${buildTypeFirebaseBlock.orEmpty()} - } - } - """ - }.orEmpty() - - val jiraConfigBlock = - jiraConfig?.let { config -> - val automation = config.automation?.let { automation -> - jiraAutomationBlock(automation) - }.orEmpty() - """ - appQualityJira { - auth { - common { - baseUrl.set("${config.auth.baseUrl}") - credentials.username.set("${config.auth.username}") - credentials.password.set("${config.auth.password}") - } - } - - $automation - } - """ - }.orEmpty() - - val playConfigBlock = - playConfig?.let { config -> - """ - appQualityPlay { - auth { - common { - apiTokenFile.set(File("${config.auth.apiTokenFilePath}")) - appId.set("${config.auth.appId}") - } - } - - distribution { - common { - trackId.set("${config.distribution.trackId}") - updatePriority.set(${config.distribution.updatePriority}) - } - } - } - """.trimIndent() - }.orEmpty() - - val slackConfigBlock = - slackConfig?.let { config -> - val uploadPath = config.bot.uploadApiTokenFilePath?.let { path -> - """uploadApiTokenFile = project.file("$path")""" - }.orEmpty() - val changelog = config.changelog?.let { changelog -> - slackChangelogBlock(changelog) - }.orEmpty() - val distribution = config.distribution?.let { distribution -> - slackDistributionBlock(distribution) - }.orEmpty() - """ - appQualitySlack { - bot { - common { - webhookUrl.set("${config.bot.webhookUrl}") - $uploadPath - iconUrl.set("${config.bot.iconUrl}") - } - } - $changelog - $distribution - } - """ - }.orEmpty() - - val telegramConfigBlock = - telegramConfig?.let { config -> - val bots = config.bots.bots.takeIf { bots -> bots.isNotEmpty() }?.let { bots -> telegramBotsBlock(bots) }.orEmpty() - val lookup = config.lookup?.let { lookup -> telegramLookupBlock(lookup) }.orEmpty() - val changelog = config.changelog?.let { changelog -> telegramChangelogBlock(changelog) }.orEmpty() - val distribution = config.distribution?.let { distribution -> telegramDistributionBlock(distribution) }.orEmpty() - """ - appQualityTelegram { - $bots - $lookup - $changelog - $distribution - } - """.trimIndent() - }.orEmpty() - - val appBuildFileContent = - """ - ${import.orEmpty()} - - plugins { - id 'com.android.application' - id 'ru.kode.android.app-quality.foundation' - } - - android { - namespace = "ru.kode.test" - buildFeatures.buildConfig = true - - compileSdk $compileSdk - - $defaultConfigBlock - - $buildTypesBlock - - $flavorDimensionsBlock - - $productFlavorsBlock - } - - $foundationConfigBlock - - $jiraConfigBlock - - $clickUpConfigBlock - - $confluenceConfigBlock - - $firebaseConfigBlock - - $playConfigBlock - - $slackConfigBlock - - $telegramConfigBlock - - """.trimIndent() - .removeEmptyLines() - .also { - println("--- ${appBuildFile.path} START ---") - println(it) - println("--- ${appBuildFile.path} END ---") - } - writeFile(appBuildFile, appBuildFileContent) - val androidManifestFileContent = - """ - - - - - - """.trimIndent().apply { - println("--- ${androidManifestFile.path} START ---") - println(this) - println("--- ${androidManifestFile.path} END ---") - } - writeFile(androidManifestFile, androidManifestFileContent) -} - -private fun jiraAutomationBlock(automation: JiraConfig.Automation): String { - val pattern = automation.fixVersionPattern?.let { pattern -> """fixVersionPattern.set("$pattern")""" }.orEmpty() - val label = automation.labelPattern?.let { label -> """labelPattern.set("$label")""" }.orEmpty() - val statusName = automation.targetStatusName?.let { statusName -> """targetStatusName.set("$statusName")""" }.orEmpty() - return """ - automation { - common { - projectKey.set("${automation.projectKey}") - $pattern - $label - $statusName - } - } - """ -} - -private fun clickUpAutomationBlock(automation: ClickUpConfig.Automation): String { - val workspaceName = automation.workspaceName.let { name -> """workspaceName.set("$name")""" } - val fixVersionPattern = automation.fixVersionPattern?.let { pattern ->"""fixVersionPattern.set("$pattern")""" }.orEmpty() - val fixVersionFieldName = automation.fixVersionFieldName?.let { name ->"""fixVersionFieldName.set("$name")""" }.orEmpty() - val tagPattern = automation.tagPattern?.let { pattern -> """tagPattern.set("$pattern")""" }.orEmpty() - return """ - automation { - common { - $workspaceName - $fixVersionPattern - $fixVersionFieldName - $tagPattern - } - } - """ -} - -private fun confluenceDistributionBlock(distribution: ConfluenceConfig.Distribution): String { - return """ - distribution { - common { - compressed.set(${distribution.compressed}) - pageId.set("${distribution.pageId}") - } - } - """ -} - -private fun telegramChangelogBlock(changelog: TelegramConfig.Changelog): String { - val userMentions = changelog.userMentions.joinToString { mention -> "\"$mention\"" } - val destinationBots = changelog.destinationBots.joinToString(separator = "\n") { bot -> telegramDestinationBotBlock(bot) } - return """ - changelog { - common { - userMentions($userMentions) - - $destinationBots - } - } - """ -} - -private fun telegramLookupBlock(changelog: TelegramConfig.Lookup): String { - val topicName = changelog.topicName?.let { name -> "topicName.set(\"${name}\")" }.orEmpty() - return """ - lookup { - botName.set("${changelog.botName}") - chatName.set("${changelog.chatName}") - $topicName - } - """ -} - -private fun slackChangelogBlock(changelog: SlackConfig.Changelog): String { - val userMentions = changelog.userMentions.joinToString { userMention -> "\"$userMention\"" } - return """ - changelog { - common { - userMentions($userMentions) - attachmentColor.set("${changelog.attachmentColor}") - } - } - """ -} - -private fun telegramBotsBlock(bots: List): String { - val bots = bots.joinToString("\n") { bot -> telegramBotBlock(bot) } - return """ - bots { - common { - $bots - } - } - """ -} - -private fun telegramDistributionBlock(distribution: TelegramConfig.Distribution): String { - val bots = distribution.destinationBots.joinToString("\n") { bot -> telegramDestinationBotBlock(bot) } - return """ - distribution { - common { - compressed.set(${distribution.compressed}) - $bots - } - } - """ -} - -private fun slackDistributionBlock(distribution: SlackConfig.Distribution): String { - val channels = distribution.destinationChannels.joinToString { channel -> "\"$channel\"" } - return """ - distribution { - common { - destinationChannels($channels) - } - } - """ -} - -private fun telegramBotBlock(bot: TelegramConfig.Bot): String { - val baseUrk = bot.botServerBaseUrl?.let { url -> """botServerBaseUrl.set("$url")""" }.orEmpty() - val userName = bot.botServerUsername?.let { username -> """botServerAuth.username.set("$username")""" }.orEmpty() - val userPassword = bot.botServerPassword?.let { password -> """botServerAuth.password.set("$password")""" }.orEmpty() - val chat = bot.chats.joinToString(separator = "\n") { chat -> telegramBotChatBlock(chat) } - return """ - bot("${bot.botName}") { - botId.set("${bot.botId}") - $baseUrk - $userName - $userPassword - - $chat - } - """ -} - -private fun telegramBotChatBlock(chat: TelegramConfig.Chat): String { - val chatId = chat.chatId?.let { id -> "chatId = \"$id\"" }.orEmpty() - val topicId = chat.topicId?.let { id -> "topicId = \"$id\"" }.orEmpty() - return """ - chat("${chat.chatName}") { - $chatId - $topicId - } - """ -} - -private fun telegramDestinationBotBlock(destinationBot: TelegramConfig.DestinationBot): String { - val chatNames = destinationBot.chatNames.joinToString { name -> "\"$name\"" } - return """ - destinationBot { - botName = "${destinationBot.botName}" - chatNames($chatNames) - } - """ -} - -private fun buildTagPatternBlock(items: List): String { - val pattern = items.joinToString(separator = "\n") { item -> " $item" } - return """ - buildTagPattern { -$pattern - } - """ -} - -@Throws(IOException::class) -private fun writeFile( - destination: File, - content: String, -) { - var output: BufferedWriter? = null - try { - output = BufferedWriter(FileWriter(destination)) - output.write(content) - } finally { - output?.close() - } -} - -private fun File.printFilesRecursivelyInternal( - prefix: String, - filterFile: (File) -> Boolean, - filterDirectory: (File) -> Boolean, -): Boolean { - if (!this.isDirectory) { - println("Not a directory: ${this.path}") - return true - } - this.listFiles()?.forEach { file -> - if (file.isFile && filterFile(file)) { - println("$prefix${file.path}") - } else if (file.isDirectory && filterDirectory(file)) { - file.printFilesRecursivelyInternal(prefix, filterFile, filterDirectory) - } - } - return false -} - -private fun String.removeEmptyLines(): String { - return this.lines() - .filter { line -> line.trim().isNotEmpty() } - .joinToString("\n") -} - -fun File.printFilesRecursively(prefix: String = "") { - println("--- FILES START ---") - printFilesRecursivelyInternal( - prefix, - filterFile = { file -> - val ext = file.extension - ext.contains("apk") || ext.contains("json") || ext.contains("aab") || ext.contains("txt") - }, - filterDirectory = { directory -> directory.endsWith("build") || directory.path.contains("outputs") || directory.path.contains("renamed") || directory.path.contains("intermediates") }, - ) - println("--- FILES END ---") -} - -fun File.getFile(path: String): File { - val file = File(this, path) - file.parentFile.mkdirs() - return file -} - -fun File.runTask( - task: String, - taskArguments: Map = emptyMap(), - agpClasspath: List = emptyList(), - gradleVersion: String = "9.2.1", - gradleJvmArgs: List = emptyList() -): BuildResult { - val args = mutableListOf(task).apply { - if (!IS_CI) add("--info") - add("--stacktrace") - taskArguments.forEach { (key, value) -> - add("-D$key=$value") - } - } - val env = System.getenv().toMutableMap().apply { - if (gradleJvmArgs.isNotEmpty()) { - this["GRADLE_OPTS"] = gradleJvmArgs.joinToString(" ") - } - } - return GradleRunner.create() - .withProjectDir(this) - .withArguments(args) - .withEnvironment(env) - .apply { - if (agpClasspath.isNotEmpty()) { - withPluginClasspath(prepareClasspath(agpClasspath)) - } else { - withPluginClasspath() - } - } - .withGradleVersion(gradleVersion) - .forwardOutput() - .build() -} - -fun File.runTasks( - vararg tasks: String, - taskArguments: Map = emptyMap(), - agpClasspath: List = emptyList(), - gradleVersion: String = "9.2.1", - gradleJvmArgs: List = emptyList() -): BuildResult { - val args = tasks.toMutableList().apply { - if (!IS_CI) add("--info") - add("--stacktrace") - taskArguments.forEach { (key, value) -> - add("-D$key=$value") - } - } - val env = System.getenv().toMutableMap().apply { - if (gradleJvmArgs.isNotEmpty()) { - this["GRADLE_OPTS"] = gradleJvmArgs.joinToString(" ") - } - } - return GradleRunner.create() - .withProjectDir(this) - .withArguments(args) - .withEnvironment(env) - .apply { - if (agpClasspath.isNotEmpty()) { - withPluginClasspath(prepareClasspath(agpClasspath)) - } else { - withPluginClasspath() - } - } - .withGradleVersion(gradleVersion) - .forwardOutput() - .build() -} - -fun File.runTaskWithFail( - task: String, - taskArguments: Map = emptyMap(), - agpClasspath: List = emptyList(), - gradleVersion: String = "9.2.1", - gradleJvmArgs: List = emptyList() -): BuildResult { - val args = mutableListOf(task, "--stacktrace").apply { - if (!IS_CI) add("--info") - taskArguments.forEach { (key, value) -> - add("-D$key=$value") - } - } - val env = System.getenv().toMutableMap().apply { - if (gradleJvmArgs.isNotEmpty()) { - this["GRADLE_OPTS"] = gradleJvmArgs.joinToString(" ") - } - } - return GradleRunner.create() - .withProjectDir(this) - .withArguments(args) - .withEnvironment(env) - .apply { - if (agpClasspath.isNotEmpty()) { - withPluginClasspath(prepareClasspath(agpClasspath)) - } else { - withPluginClasspath() - } - } - .withGradleVersion(gradleVersion) - .forwardOutput() - .buildAndFail() -} - -private fun prepareClasspath(agpClassPath: List): List { - val pluginClasspath: List = PluginUnderTestMetadataReading.readImplementationClasspath() - val filteredClasspath = pluginClasspath.filter { file -> - val name = file.name.lowercase() - !name.startsWith("gradle") || - !name.contains("android") || - !name.contains("agp") - } - println("Filtered ${filteredClasspath.size} classpath items, adding ${agpClassPath.size} AGP JARs") - return filteredClasspath + agpClassPath -} - -fun resolveRequiredAgpJars(agpVersion: String): List { - val project = ProjectBuilder.builder() - .withName("temp-resolver") - .build() - - project.buildscript.repositories.apply { - google() - mavenCentral() - } - - val pluginClasspath = project.buildscript.configurations.getByName("classpath").apply { - dependencies.clear() - dependencies.add(project.dependencies.create("com.android.tools.build:gradle:$agpVersion")) - dependencies.add(project.dependencies.create("com.android.application:com.android.application.gradle.plugin:$agpVersion")) - }.resolve() - - return pluginClasspath.toList() -} - - -data class BuildType( - val name: String, - val appId: String? = null, - val applicationIdSuffix: String? = ".${name}", -) - -data class ProductFlavor( - val name: String, - val dimension: String, -) - -data class FoundationConfig( - val bodyLogging: Boolean = false, - val verboseLogging: Boolean = true, - val output: Output = Output(), - val buildTypeOutput: Pair? = null, - val buildTypeOutput2: Pair? = null, - val buildTypeOutput3: Pair? = null, - val changelog: Changelog = Changelog(), -) { - data class Output( - val baseFileName: String = "test-app", - val useVersionsFromTag: Boolean? = null, - val useStubsForTagAsFallback: Boolean? = null, - val useDefaultsForVersionsAsFallback: Boolean? = null, - val buildTagPatternBuilderFunctions: List? = null, - val versionNameStrategy: String? = null, - val versionCodeStrategy: String? = null, - ) - - data class Changelog( - val issueNumberPattern: String = "TICKET-\\\\d+", - val issueUrlPrefix: String = "https://jira.example.com/browse/", - val commitMessageKey: String = "CHANGELOG", - val changelogMessageStrategy: String? = null, - val versionNameStrategy: String? = null, - val versionCodeStrategy: String? = null, - ) -} - -data class ClickUpConfig( - val auth: Auth, - val automation: Automation?, -) { - data class Auth( - val apiTokenFilePath: String, - ) - - data class Automation( - val workspaceName: String, - val fixVersionPattern: String?, - val fixVersionFieldName: String?, - val tagPattern: String?, - ) -} - -data class ConfluenceConfig( - val auth: Auth, - val distribution: Distribution?, -) { - data class Auth( - val baseUrl: String, - val username: String, - val password: String, - ) - - data class Distribution( - val compressed: Boolean = false, - val pageId: String, - ) -} - -data class FirebaseConfig( - val distributionCommon: Distribution, - val distributionBuildType: Pair? = null, -) { - data class Distribution( - val serviceCredentialsFilePath: String, - val artifactType: String, - val appId: String, - val testerGroups: List?, - ) -} - -data class JiraConfig( - val auth: Auth, - val automation: Automation?, -) { - data class Auth( - val baseUrl: String, - val username: String, - val password: String, - ) - - data class Automation( - val projectKey: String, - val labelPattern: String?, - val fixVersionPattern: String?, - val targetStatusName: String?, - ) -} - -data class PlayConfig( - val auth: Auth, - val distribution: Distribution, -) { - data class Auth( - val apiTokenFilePath: String, - val appId: String, - ) - - data class Distribution( - val trackId: String, - val updatePriority: Int, - ) -} - -data class SlackConfig( - val bot: Bot, - val changelog: Changelog?, - val distribution: Distribution?, -) { - data class Bot( - val webhookUrl: String, - val uploadApiTokenFilePath: String?, - val iconUrl: String, - ) - - data class Changelog( - val userMentions: List, - val attachmentColor: String, - ) - - data class Distribution( - val destinationChannels: List, - ) -} - -data class DefaultConfig( - val applicationId: String = "com.example.build.types.android", - val versionCode: Int? = 1, - val versionName: String? = "1.0", - val minSdk: Int = 26, - val targetSdk: Int = 36, -) - -data class TelegramConfig( - val bots: Bots, - val lookup: Lookup? = null, - val changelog: Changelog?, - val distribution: Distribution?, -) { - data class Lookup( - val botName: String, - val chatName: String, - val topicName: String? - ) - - data class Bots( - val bots: List, - ) - - data class Changelog( - val userMentions: List, - val destinationBots: List, - ) - - data class Distribution( - val compressed: Boolean = false, - val destinationBots: List, - ) - - data class Bot( - val botName: String, - val botId: String, - val botServerBaseUrl: String?, - val botServerUsername: String?, - val botServerPassword: String?, - val chats: List, - ) - - data class DestinationBot( - val botName: String, - val chatNames: List, - ) - - data class Chat( - val chatName: String, - val chatId: String?, - val topicId: String?, - ) -} diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/ApkExtensions.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/ApkExtensions.kt deleted file mode 100644 index a882801..0000000 --- a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/ApkExtensions.kt +++ /dev/null @@ -1,59 +0,0 @@ -package ru.kode.android.app.quality.plugin.test.utils - -import java.io.File -import java.time.LocalDate -import java.time.format.DateTimeFormatter - -val currentDate: String - get() = LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")) - -private val sdkPath - get() = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") - -private val apkAnalyzerPath: String - get() { - val sdk = sdkPath ?: error("ANDROID_HOME or ANDROID_SDK_ROOT not set") - val buildToolsDir = File(sdk, "build-tools") - val buildToolsVersion = buildToolsDir.listFiles()?.maxByOrNull { it.name } - val fileName = if (isWindows()) "apkanalyzer.bat" else "apkanalyzer" - val candidate = buildToolsVersion?.resolve(fileName) - return if (candidate?.exists() == true) { - candidate.absolutePath - } else { - val filePath = "cmdline-tools/latest/bin/$fileName" - File(sdk, filePath).absolutePath - } - } - -private fun isWindows(): Boolean { - return System.getProperty("os.name").startsWith("Windows", ignoreCase = true) -} - -fun File.extractManifestProperties(): ManifestProperties { - val manifestOutput = - ProcessBuilder() - .command(apkAnalyzerPath, "manifest", "print", this.absolutePath) - .start() - .inputStream - .bufferedReader() - .readText() - - println("--- MANIFEST START ---") - println(manifestOutput) - println("--- MANIFEST END ---") - - val versionCodeMatch = Regex("versionCode=\"(\\d+)\"").find(manifestOutput) - val versionNameMatch = Regex("versionName=\"([^\"]+)\"").find(manifestOutput) - val versionCode = versionCodeMatch?.groupValues?.get(1).orEmpty() - val versionName = versionNameMatch?.groupValues?.get(1).orEmpty() - - return ManifestProperties( - versionCode = versionCode, - versionName = versionName, - ) -} - -data class ManifestProperties( - val versionCode: String, - val versionName: String, -) diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GradleRunners.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GradleRunners.kt new file mode 100644 index 0000000..a91facc --- /dev/null +++ b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GradleRunners.kt @@ -0,0 +1,122 @@ +package ru.kode.android.app.quality.plugin.test.utils + +import org.gradle.testfixtures.ProjectBuilder +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.internal.PluginUnderTestMetadataReading +import java.io.File + +private val IS_CI get() = System.getenv("CI") == "true" + +const val DEFAULT_GRADLE_VERSION = "9.4.1" + +fun File.getFile(path: String): File { + val file = File(this, path) + file.parentFile.mkdirs() + return file +} + +@Suppress("LongParameterList") +fun File.runTasks( + vararg tasks: String, + arguments: List = emptyList(), + taskArguments: Map = emptyMap(), + agpClasspath: List = emptyList(), + gradleVersion: String = DEFAULT_GRADLE_VERSION, + gradleJvmArgs: List = emptyList(), + expectFailure: Boolean = false, +): BuildResult { + val args = + tasks.toMutableList().apply { + if (!IS_CI) add("--info") + add("--stacktrace") + addAll(arguments) + taskArguments.forEach { (key, value) -> + add("-D$key=$value") + } + } + val env = + System.getenv().toMutableMap().apply { + if (gradleJvmArgs.isNotEmpty()) { + this["GRADLE_OPTS"] = gradleJvmArgs.joinToString(" ") + } + } + val runner = + GradleRunner.create() + .withProjectDir(this) + .withArguments(args) + .withEnvironment(env) + .apply { + if (agpClasspath.isNotEmpty()) { + withPluginClasspath(prepareClasspath(agpClasspath)) + } else { + withPluginClasspath() + } + } + .withGradleVersion(gradleVersion) + .forwardOutput() + return if (expectFailure) runner.buildAndFail() else runner.build() +} + +fun File.runTask( + task: String, + arguments: List = emptyList(), + gradleVersion: String = DEFAULT_GRADLE_VERSION, +): BuildResult { + return runTasks(task, arguments = arguments, gradleVersion = gradleVersion) +} + +fun File.runTaskWithFail( + task: String, + arguments: List = emptyList(), + gradleVersion: String = DEFAULT_GRADLE_VERSION, +): BuildResult { + return runTasks(task, arguments = arguments, gradleVersion = gradleVersion, expectFailure = true) +} + +private fun prepareClasspath(agpClassPath: List): List { + val pluginClasspath: List = PluginUnderTestMetadataReading.readImplementationClasspath() + // Drop the default AGP artifacts (resolved from the `com.android.*` groups) so the + // injected AGP version fully replaces them instead of clashing on the classpath. + val filteredClasspath = + pluginClasspath.filter { file -> + !file.path.replace('\\', '/').contains("/com.android") + } + val dropped = pluginClasspath.size - filteredClasspath.size + println("Dropped $dropped default AGP jars, adding ${agpClassPath.size} AGP jars") + return filteredClasspath + agpClassPath +} + +/** + * Resolves arbitrary dependency notations (with transitives) through a throwaway project — + * used to obtain tool jars for classpath-injection and file-based configuration tests. + */ +fun resolveJars(vararg notations: String): List { + val project = + ProjectBuilder.builder() + .withName("temp-resolver") + .build() + + project.buildscript.repositories.apply { + google() + mavenCentral() + } + + val resolved = + project.buildscript.configurations.getByName("classpath").apply { + dependencies.clear() + notations.forEach { notation -> + dependencies.add(project.dependencies.create(notation)) + } + }.resolve() + + return resolved.toList() +} + +fun resolveRequiredAgpJars(agpVersion: String): List { + val agpPluginMarker = "com.android.application:com.android.application.gradle.plugin" + return resolveJars( + "com.android.tools.build:gradle:$agpVersion", + "$agpPluginMarker:$agpVersion", + ) +} diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GrgitExtensions.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GrgitExtensions.kt index 25259de..616c818 100644 --- a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GrgitExtensions.kt +++ b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/GrgitExtensions.kt @@ -20,7 +20,7 @@ fun File.initGit(bare: Boolean = false): Grgit { mapOf("dir" to this, "bare" to true) } else { mapOf("dir" to this) - } + }, ) } @@ -64,14 +64,14 @@ fun Grgit.commitAmend(message: String) { this.commit( mapOf( "amend" to true, - "message" to message - ) + "message" to message, + ), ) } fun Grgit.switchBranch(name: String) { this.checkout( - mapOf("branch" to name) + mapOf("branch" to name), ) } @@ -79,8 +79,8 @@ fun Grgit.createAndSwitchBranch(name: String) { this.checkout( mapOf( "branch" to name, - "createBranch" to true - ) + "createBranch" to true, + ), ) } @@ -88,18 +88,23 @@ fun Grgit.currentBranch(): String { return this.branch.current().name } -fun Grgit.commitWithDate(message: String, date: Instant): Commit { - val author = PersonIdent( - "author", - "author@example.com", - date, - ZoneId.systemDefault() - ) - val commit = this.repository.jgit.commit().apply { - this.message = message - this.author = author - this.committer = author - }.call() +fun Grgit.commitWithDate( + message: String, + date: Instant, +): Commit { + val author = + PersonIdent( + "author", + "author@example.com", + date, + ZoneId.systemDefault(), + ) + val commit = + this.repository.jgit.commit().apply { + this.message = message + this.author = author + this.committer = author + }.call() return JGitUtil.convertCommit(this.repository, commit) } diff --git a/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/QualityProjectBuilders.kt b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/QualityProjectBuilders.kt new file mode 100644 index 0000000..5d641eb --- /dev/null +++ b/plugin-test/utils/src/main/java/ru/kode/android/app/quality/plugin/test/utils/QualityProjectBuilders.kt @@ -0,0 +1,600 @@ +package ru.kode.android.app.quality.plugin.test.utils + +import java.io.File + +/** + * Module archetypes supported by the generated test project. + */ +enum class ModuleType { + AndroidApp, + AndroidLib, + KotlinJvm, + JavaOnly, +} + +/** + * Mirrors an `ExternalDependencyConfig` slot: all source kinds plus the defaults toggle. + * [refs] are typed catalog accessor expressions (e.g. "deps.ktlint.cli"), [notations] are + * string coordinates, [files] are root-relative jar paths. + */ +data class DependencySlot( + val refs: List? = null, + val notations: List? = null, + val files: List? = null, + val useDefaults: Boolean? = null, +) + +/** + * Mirrors a `SourcePatternsConfig` slot: add-only `include`/`exclude` globs (bare, no `!` + * prefix) plus the defaults toggle. + */ +data class SourcePatternsSlot( + val include: List? = null, + val exclude: List? = null, + val useDefaults: Boolean? = null, +) + +/** + * Mirrors the `appQualityFoundation { ktlint { ... } }` block. All paths are relative to the project root. + */ +data class KtlintBlock( + val projectConfigPath: String? = null, + val sources: SourcePatternsSlot? = null, + val cli: DependencySlot? = null, +) + +/** + * Mirrors `appQualityFoundation { detekt { kotlin/android/compose { ... } } }` platform blocks. + */ +data class PlatformDetektBlock( + val projectConfigPath: String? = null, + val rules: DependencySlot? = null, +) + +/** + * Mirrors the `appQualityFoundation { detekt { ... } }` block. + */ +data class DetektBlock( + val ignoredBuildTypes: List? = null, + val sources: SourcePatternsSlot? = null, + val typeResolution: Boolean? = null, + val kotlin: PlatformDetektBlock? = null, + val android: PlatformDetektBlock? = null, + val compose: PlatformDetektBlock? = null, +) + +/** + * Mirrors the whole `appQualityFoundation { ... }` extension. Null values are omitted from + * the generated build script, so the plugin's own defaults/conventions apply. + */ +data class QualityConfig( + val verboseLogging: Boolean? = null, + val jvmTarget: String? = null, + val gitHooksPath: String? = null, + val gitHooksEnabled: Boolean? = null, + val ktlint: KtlintBlock? = null, + val detekt: DetektBlock? = null, + /** Raw Groovy lines appended verbatim inside the appQualityFoundation block. */ + val extraExtensionContent: String? = null, +) + +/** + * One module of the generated multi-module project. + * + * [buildTypes] lists ADDITIONAL android build types (debug/release always exist). + * [kotlinSources]/[javaSources] map module-relative paths to file content. + */ +data class ModuleSpec( + val name: String, + val type: ModuleType, + val buildTypes: List = emptyList(), + val detektKotlinConfigContent: String? = null, + val detektAndroidConfigContent: String? = null, + val detektComposeConfigContent: String? = null, + val kotlinSources: Map = emptyMap(), + val javaSources: Map = emptyMap(), + val compileSdk: Int = 36, + // Applies org.jetbrains.kotlin.android explicitly — the pre-AGP-9 setup where detekt + // registers variant tasks (detektDebug, detektRelease). Use with an injected AGP 8.x. + val applyKotlinAndroidPlugin: Boolean = false, + // Applies org.jetbrains.kotlin.plugin.compose so the plugin's compose detekt layer kicks in. + val applyComposePlugin: Boolean = false, + // Applies org.jetbrains.compose (JetBrains Compose Multiplatform) alongside + // org.jetbrains.kotlin.plugin.compose, which it requires since Compose Multiplatform + // 1.6.10. The plugin's compose detekt layer triggers on either compose plugin id. + val applyJetbrainsComposePlugin: Boolean = false, + // Applies org.jetbrains.kotlin.multiplatform with a minimal `kotlin { jvm() }` target. + val applyMultiplatformPlugin: Boolean = false, +) + +/** + * Controls the generated version catalog. With the default [name] "libs" the toml is written + * to gradle/libs.versions.toml (Gradle auto-loads it); a custom name writes + * gradle/.versions.toml plus an explicit versionCatalogs declaration — and NO `libs` + * catalog exists at all, which is what the decoupling tests need. Omitting aliases enables + * negative tests of the error messages; [generate] = false produces a project with no catalog. + */ +data class LibsCatalog( + val name: String = "libs", + val generate: Boolean = true, + val includeKtlintCli: Boolean = true, + val includeDetektFormatting: Boolean = true, + val includeDetektComposeRules: Boolean = true, +) + +/** + * Generates a multi-module Gradle project applying `ru.kode.android.app-quality.foundation` + * at the ROOT project (the plugin's real usage mode) with the given extension configuration. + */ +@Suppress("LongParameterList") +fun File.createQualityProject( + modules: List, + qualityConfig: QualityConfig = QualityConfig(), + rootEditorConfigContent: String? = null, + extraRootFiles: Map = emptyMap(), + rulesJar: File? = null, + libsCatalog: LibsCatalog = LibsCatalog(), + gradleProperties: Map = emptyMap(), + useKotlinDsl: Boolean = false, +) { + val settingsFileName = if (useKotlinDsl) "settings.gradle.kts" else "settings.gradle" + val buildFileName = if (useKotlinDsl) "build.gradle.kts" else "build.gradle" + writeFileVerbose(getFile(settingsFileName), settingsFileContent(modules, libsCatalog, useKotlinDsl)) + writeFileVerbose(getFile(buildFileName), rootBuildFileContent(qualityConfig, useKotlinDsl)) + if (libsCatalog.generate) { + writeFileVerbose(getFile(libsCatalog.tomlPath()), libsCatalogContent(libsCatalog)) + } + + val properties = mapOf("org.gradle.jvmargs" to "-Xmx2g") + gradleProperties + writeFileVerbose( + getFile("gradle.properties"), + properties.entries.joinToString("\n") { (key, value) -> "$key=$value" }, + ) + + androidSdkPath()?.let { sdk -> + writeFileVerbose(getFile("local.properties"), "sdk.dir=$sdk") + } + + rootEditorConfigContent?.let { writeFileVerbose(getFile(".editorconfig"), it) } + extraRootFiles.forEach { (path, content) -> writeFileVerbose(getFile(path), content) } + rulesJar?.let { jar -> jar.copyTo(getFile("libs/detekt-rules-1.4.0.jar"), overwrite = true) } + + modules.forEach { module -> writeModule(module, useKotlinDsl) } +} + +private fun File.writeModule( + module: ModuleSpec, + useKotlinDsl: Boolean, +) { + val moduleDir = File(this, module.name) + val buildFileName = if (useKotlinDsl) "build.gradle.kts" else "build.gradle" + writeFileVerbose(moduleDir.getFile(buildFileName), moduleBuildFileContent(module, useKotlinDsl)) + + if (module.type == ModuleType.AndroidApp || module.type == ModuleType.AndroidLib) { + writeFileVerbose( + moduleDir.getFile("src/main/AndroidManifest.xml"), + """ + + + + + """.trimIndent(), + ) + } + + module.detektKotlinConfigContent?.let { + writeFileVerbose(moduleDir.getFile("detekt-kotlin-config.yml"), it) + } + module.detektAndroidConfigContent?.let { + writeFileVerbose(moduleDir.getFile("detekt-android-config.yml"), it) + } + module.detektComposeConfigContent?.let { + writeFileVerbose(moduleDir.getFile("detekt-compose-config.yml"), it) + } + module.kotlinSources.forEach { (path, content) -> + writeFileVerbose(moduleDir.getFile(path), content) + } + module.javaSources.forEach { (path, content) -> + writeFileVerbose(moduleDir.getFile(path), content) + } +} + +private fun LibsCatalog.tomlPath(): String { + return if (name == "libs") "gradle/libs.versions.toml" else "gradle/$name.versions.toml" +} + +private fun settingsFileContent( + modules: List, + libsCatalog: LibsCatalog, + useKotlinDsl: Boolean, +): String { + val includes = + modules.joinToString("\n") { + if (useKotlinDsl) "include(\":${it.name}\")" else "include ':${it.name}'" + } + // gradle/libs.versions.toml is auto-loaded as `libs`; a custom name needs a declaration. + val versionCatalogsBlock = + if (libsCatalog.generate && libsCatalog.name != "libs") { + if (useKotlinDsl) { + """ + versionCatalogs { + create("${libsCatalog.name}") { + from(files("${libsCatalog.tomlPath()}")) + } + } + """ + } else { + """ + versionCatalogs { + create('${libsCatalog.name}') { + from(files('${libsCatalog.tomlPath()}')) + } + } + """ + } + } else { + "" + } + return """ + pluginManagement { + repositories { + mavenLocal() + google() + mavenCentral() + gradlePluginPortal() + } + } + + dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + } + $versionCatalogsBlock + } + + rootProject.name = "quality-test-project" + $includes + """.trimIndent().removeBlankLines() +} + +private fun rootBuildFileContent( + config: QualityConfig, + useKotlinDsl: Boolean, +): String { + val lines = mutableListOf() + config.verboseLogging?.let { lines += "verboseLogging.set($it)" } + config.jvmTarget?.let { lines += "jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.$it)" } + config.gitHooksPath?.let { + lines += "gitHooks.set(rootProject.layout.projectDirectory.file(\"$it\"))" + } + config.gitHooksEnabled?.let { lines += "gitHooksEnabled.set($it)" } + config.ktlint?.let { ktlint -> + ktlint.projectConfigPath?.let { + lines += "ktlint.projectConfig.set(rootProject.layout.projectDirectory.file(\"$it\"))" + } + ktlint.sources?.let { sources -> + lines += sources.slotLines("ktlint.sources", useKotlinDsl) + } + ktlint.cli?.let { slot -> + lines += slot.slotLines("ktlint.cli") + } + } + config.detekt?.let { detekt -> + detekt.ignoredBuildTypes?.let { lines += "detekt.ignoredBuildTypes.set(${it.toGroovyList(useKotlinDsl)})" } + detekt.sources?.let { sources -> + lines += sources.slotLines("detekt.sources", useKotlinDsl) + } + detekt.typeResolution?.let { lines += "detekt.typeResolution.set($it)" } + lines += detekt.kotlin.platformLines("kotlin") + lines += detekt.android.platformLines("android") + lines += detekt.compose.platformLines("compose") + } + + config.extraExtensionContent?.let { extra -> + lines += extra.trimIndent().lines() + } + + val extensionBlock = + if (lines.isEmpty()) { + "" + } else { + """ + appQualityFoundation { + ${lines.joinToString("\n ")} + } + """.trimIndent() + } + + val pluginsBlock = + if (useKotlinDsl) { + "id(\"ru.kode.android.app-quality.foundation\")" + } else { + "id 'ru.kode.android.app-quality.foundation'" + } + return """ + plugins { + $pluginsBlock + } + + $extensionBlock + """.trimIndent().removeBlankLines() +} + +private fun PlatformDetektBlock?.platformLines(platform: String): List { + if (this == null) return emptyList() + val lines = mutableListOf() + projectConfigPath?.let { + lines += "detekt.$platform.projectConfig.set(rootProject.layout.projectDirectory.file(\"$it\"))" + } + rules?.let { slot -> + lines += slot.slotLines("detekt.$platform.rules") + } + return lines +} + +/** + * Emits the Groovy DSL for one ExternalDependencyConfig slot as a CLOSURE block — exercising + * both the Closure overload of the slot method and the DependencyCollector call-sugar + * (`from 'g:n:v'`, `from files(...)`) that consumers use. + */ +private fun DependencySlot.slotLines(path: String): List { + val inner = mutableListOf() + refs?.forEach { ref -> inner += "from($ref)" } + notations?.forEach { notation -> inner += "from(\"$notation\")" } + files?.let { paths -> inner += "from(files(${paths.toGroovyRootFiles()}))" } + useDefaults?.let { inner += "useDefaults.set($it)" } + if (inner.isEmpty()) return emptyList() + return listOf("$path {") + inner.map { " $it" } + "}" +} + +/** + * Emits the DSL for one SourcePatternsConfig slot as a block: `include`/`exclude` are + * plain ListProperty assignments, no `!` prefix (the plugin adds it for ktlint CLI args). + */ +private fun SourcePatternsSlot.slotLines( + path: String, + useKotlinDsl: Boolean = false, +): List { + val inner = mutableListOf() + include?.let { inner += "include.set(${it.toGroovyList(useKotlinDsl)})" } + exclude?.let { inner += "exclude.set(${it.toGroovyList(useKotlinDsl)})" } + useDefaults?.let { inner += "useDefaults.set($it)" } + if (inner.isEmpty()) return emptyList() + return listOf("$path {") + inner.map { " $it" } + "}" +} + +private fun List.toGroovyRootFiles(): String { + return joinToString { path -> "rootProject.layout.projectDirectory.file(\"$path\")" } +} + +private fun ModuleSpec.packageName(): String = name.replace('-', '_') + +private fun pluginId( + id: String, + useKotlinDsl: Boolean, +): String = if (useKotlinDsl) "id(\"$id\")" else "id '$id'" + +private fun moduleBuildFileContent( + module: ModuleSpec, + useKotlinDsl: Boolean, +): String { + return when (module.type) { + ModuleType.AndroidApp, ModuleType.AndroidLib -> { + val androidPluginId = + if (module.type == ModuleType.AndroidApp) { + "com.android.application" + } else { + "com.android.library" + } + val defaultConfigBlock = + if (module.type == ModuleType.AndroidApp) { + if (useKotlinDsl) { + """ + defaultConfig { + applicationId = "ru.kode.test.${module.packageName()}" + minSdk = 26 + targetSdk = ${module.compileSdk} + versionCode = 1 + versionName = "1.0" + } + """ + } else { + """ + defaultConfig { + applicationId "ru.kode.test.${module.packageName()}" + minSdk 26 + targetSdk ${module.compileSdk} + versionCode 1 + versionName "1.0" + } + """ + } + } else if (useKotlinDsl) { + """ + defaultConfig { + minSdk = 26 + } + """ + } else { + """ + defaultConfig { + minSdk 26 + } + """ + } + val buildTypesBlock = + module.buildTypes + .takeIf { it.isNotEmpty() } + ?.joinToString(separator = "\n", prefix = "buildTypes {\n", postfix = "\n}") { + if (useKotlinDsl) " create(\"$it\") { }" else " $it { }" + } + .orEmpty() + val kotlinAndroidPlugin = + if (module.applyKotlinAndroidPlugin) pluginId("org.jetbrains.kotlin.android", useKotlinDsl) else "" + val composePlugin = + if (module.applyJetbrainsComposePlugin) { + // Since Compose Multiplatform 1.6.10, org.jetbrains.compose requires the + // Kotlin compose compiler plugin applied alongside it. + pluginId("org.jetbrains.compose", useKotlinDsl) + "\n" + + pluginId("org.jetbrains.kotlin.plugin.compose", useKotlinDsl) + } else if (module.applyComposePlugin) { + pluginId("org.jetbrains.kotlin.plugin.compose", useKotlinDsl) + } else { + "" + } + // Keep Java and Kotlin JVM targets aligned, otherwise KGP fails compilation + // with "Inconsistent JVM Target Compatibility". + val jvmTargetAlignment = + if (module.applyKotlinAndroidPlugin) { + if (useKotlinDsl) { + """ + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + """ + } else { + """ + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + """ + } + } else { + "" + } + val compileSdkLine = + if (useKotlinDsl) "compileSdk = ${module.compileSdk}" else "compileSdk ${module.compileSdk}" + """ + plugins { + ${pluginId(androidPluginId, useKotlinDsl)} + $kotlinAndroidPlugin + $composePlugin + } + + android { + namespace = "ru.kode.test.${module.packageName()}" + $compileSdkLine + + $jvmTargetAlignment + + $defaultConfigBlock + + $buildTypesBlock + } + """.trimIndent().removeBlankLines() + } + + ModuleType.KotlinJvm -> { + val kotlinPlugin = + if (module.applyMultiplatformPlugin) { + pluginId("org.jetbrains.kotlin.multiplatform", useKotlinDsl) + } else { + pluginId("org.jetbrains.kotlin.jvm", useKotlinDsl) + } + val composePlugin = + if (module.applyJetbrainsComposePlugin) { + pluginId("org.jetbrains.compose", useKotlinDsl) + "\n" + + pluginId("org.jetbrains.kotlin.plugin.compose", useKotlinDsl) + } else if (module.applyComposePlugin) { + pluginId("org.jetbrains.kotlin.plugin.compose", useKotlinDsl) + } else { + "" + } + val kotlinBlock = + if (module.applyMultiplatformPlugin) { + """ + kotlin { + jvm() + } + """ + } else { + "" + } + """ + plugins { + $kotlinPlugin + $composePlugin + } + + $kotlinBlock + """.trimIndent().removeBlankLines() + } + + ModuleType.JavaOnly -> + """ + plugins { + ${pluginId("java", useKotlinDsl)} + } + """.trimIndent() + } +} + +private fun libsCatalogContent(catalog: LibsCatalog): String { + val libraries = mutableListOf() + if (catalog.includeKtlintCli) { + libraries += """ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlintCli" }""" + } + if (catalog.includeDetektFormatting) { + val module = "io.gitlab.arturbosch.detekt:detekt-formatting" + libraries += + """detekt-formatting = { module = "$module", version.ref = "detekt" }""" + } + if (catalog.includeDetektComposeRules) { + libraries += + """detekt-compose-rules = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" }""" + } + return """ + [versions] + detekt = "1.23.8" + ktlintCli = "1.8.0" + detektComposeRules = "1.4.0" + + [libraries] + ${libraries.joinToString("\n ")} + """.trimIndent() +} + +private fun List.toGroovyList(useKotlinDsl: Boolean = false): String = + if (useKotlinDsl) { + joinToString(prefix = "listOf(", postfix = ")") { "\"$it\"" } + } else { + joinToString(prefix = "[", postfix = "]") { "'$it'" } + } + +private fun String.removeBlankLines(): String { + return lines() + .filter { line -> line.isNotBlank() } + .joinToString("\n") +} + +private fun androidSdkPath(): String? { + val fromEnv = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + if (fromEnv != null) return fromEnv + val candidates = + listOf( + File(System.getProperty("user.home"), "Library/Android/sdk"), + File("/Volumes/Projects/library/Android/sdk"), + ) + return candidates.firstOrNull { it.isDirectory }?.absolutePath +} + +private fun writeFileVerbose( + destination: File, + content: String, +) { + println("--- ${destination.path} START ---") + println(content) + println("--- ${destination.path} END ---") + destination.writeText(content) +}