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 e708b1c..eddabd2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..1403a5a Binary files /dev/null and b/plugin-build/plugin-foundation/src/main/resources/detekt/rules/kode-android-rules-1.4.0.jar differ 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) +}