@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Single source of truth for skills delivered to both Claude Code (`~/.claude/skills/`) and Copilot CLI (`~/.copilot/skills/`) by the installer.
|
|
4
4
|
|
|
5
|
-
**Total:**
|
|
5
|
+
**Total:** 219 skills (61 core + 158 external). Auto-generated by `scripts/gen-skills-index.mjs` - do not edit by hand.
|
|
6
6
|
|
|
7
7
|
## Directory layout
|
|
8
8
|
|
|
9
|
-
- **`core/`** -
|
|
10
|
-
- **`external/`** -
|
|
9
|
+
- **`core/`** - 61 `multi-agent*` orchestration skills that are pipeline-critical. Edits here are core-code changes.
|
|
10
|
+
- **`external/`** - 158 iOS / Android / generic skills imported from the upstream skill library. Mirrors of third-party guidance.
|
|
11
11
|
- Install destinations (ADR-0009): Claude Code gets NO local copy of `external/` - it loads those skills from the `multi-agent-plugins` marketplace, namespaced (`ai-<stack>-toolkit:<name>`); only the two compliance catalogs from `core/` land in `~/.claude/skills/`. Copilot CLI and Codex CLI receive a flat copy filtered to the enabled stacks. `external/` remains the single authoring source that `build-stack-plugins.mjs` publishes from.
|
|
12
12
|
|
|
13
13
|
Source layout is logical grouping only - skill discovery at runtime is unchanged.
|
|
14
14
|
|
|
15
15
|
## Categories
|
|
16
16
|
|
|
17
|
-
- [Pipeline Orchestration](#pipeline-orchestration) -
|
|
17
|
+
- [Pipeline Orchestration](#pipeline-orchestration) - 61
|
|
18
18
|
- [iOS / Apple Ecosystem](#ios-apple-ecosystem) - 90
|
|
19
|
-
- [Android / Kotlin](#android-kotlin) -
|
|
19
|
+
- [Android / Kotlin](#android-kotlin) - 17
|
|
20
20
|
- [Web](#web) - 10
|
|
21
21
|
- [Backend / API](#backend-api) - 11
|
|
22
|
-
- [Cross-cutting](#cross-cutting) -
|
|
22
|
+
- [Cross-cutting](#cross-cutting) - 30
|
|
23
23
|
|
|
24
24
|
## Pipeline Orchestration
|
|
25
25
|
|
|
@@ -71,6 +71,7 @@ Source layout is logical grouping only - skill discovery at runtime is unchang
|
|
|
71
71
|
| [`multi-agent-save`](./core/multi-agent-save/) | `core` | Save a recurring job as a reusable /multi-agent:<name> command. Reviews the conversation + your CLAUDE.md for candidate routines, you pick o |
|
|
72
72
|
| [`multi-agent-scan`](./core/multi-agent-scan/) | `core` | Skill security scan: walks local skill directories against a tiered pattern catalog. Use when local skill directories need checking for unsa |
|
|
73
73
|
| [`multi-agent-search`](./core/multi-agent-search/) | `core` | Log search across every agent-log.md with smart ranking and filters. Optional --semantic flag queries the per-repo triage corpus. Use when s |
|
|
74
|
+
| [`multi-agent-security-review`](./core/multi-agent-security-review/) | `core` | Run a standalone defensive, static security review of a diff, branch or repo: run-scoped threat model, reviewer-shaped findings joined to OW |
|
|
74
75
|
| [`multi-agent-setup`](./core/multi-agent-setup/) | `core` | First-run setup wizard: keychain token discovery, Git Identity onboarding, and pipeline preparation. Use when the pipeline is being set up f |
|
|
75
76
|
| [`multi-agent-stack`](./core/multi-agent-stack/) | `core` | Select the active stack(s) for this repo by enabling the matching marketplace plugin(s) in .claude/settings.json. Multi-select: pass several |
|
|
76
77
|
| [`multi-agent-status`](./core/multi-agent-status/) | `core` | Show every multi-agent task's ID, phase, branch, and status. Use when asked what is running, or for an overview of every task. |
|
|
@@ -185,11 +186,15 @@ Source layout is logical grouping only - skill discovery at runtime is unchang
|
|
|
185
186
|
|
|
186
187
|
| Skill | Group | Description |
|
|
187
188
|
|-------|-------|-------------|
|
|
188
|
-
| [`android_ui_verification`](./external/android_ui_verification/) | `external` | Automated end-to-end UI testing and verification on an Android Emulator using ADB. Use when an Android build needs driving on an emulator to |
|
|
189
189
|
| [`android-architecture`](./external/android-architecture/) | `external` | Design and implement Clean Architecture with multi-module structure on Android. Covers domain/data/presentation layers, Hilt dependency inje |
|
|
190
|
+
| [`android-build-quality-gates`](./external/android-build-quality-gates/) | `external` | Encode Android module and quality rules as build-failing Gradle tasks: layer-dependency checks, coverage floors, a Compose-stability gate, l |
|
|
191
|
+
| [`android-datastore`](./external/android-datastore/) | `external` | Typed Jetpack DataStore as the modern SharedPreferences replacement: Preferences and typed stores, serializer-layer encryption, and migratio |
|
|
192
|
+
| [`android-design-tokens-codegen`](./external/android-design-tokens-codegen/) | `external` | A single-source JSON to typed-Kotlin codegen pipeline for design tokens, UI-test ids, analytics events and deeplink manifests, run as Gradle |
|
|
190
193
|
| [`android-jetpack-compose-expert`](./external/android-jetpack-compose-expert/) | `external` | Expert guidance for building modern Android UIs with Jetpack Compose, covering state management, navigation, performance, and Material Desig |
|
|
194
|
+
| [`android-mvi-viewmodel`](./external/android-mvi-viewmodel/) | `external` | A reusable generic BaseViewModel<State, Intent, Event> for MVI Compose apps: StateFlow state, one-shot events over a rendezvous Channel, a s |
|
|
191
195
|
| [`android-performance`](./external/android-performance/) | `external` | Optimize Android app performance with Baseline Profiles for startup, Compose stability annotations (@Stable, @Immutable, strong skipping), r |
|
|
192
196
|
| [`android-security`](./external/android-security/) | `external` | Secure Android apps with ProGuard/R8 obfuscation, OkHttp certificate pinning, BiometricPrompt authentication, EncryptedSharedPreferences, Pl |
|
|
197
|
+
| [`android-ui-verification`](./external/android-ui-verification/) | `external` | Automated end-to-end UI testing and verification on an Android Emulator using ADB. Use when an Android build needs driving on an emulator to |
|
|
193
198
|
| [`compose-components`](./external/compose-components/) | `external` | Build Material 3 UI components and custom composables with Jetpack Compose. Covers TopAppBar, Scaffold, BottomSheet, SnackbarHost, slot-base |
|
|
194
199
|
| [`compose-navigation`](./external/compose-navigation/) | `external` | Implement type-safe navigation in Jetpack Compose using Navigation 2.8+ with @Serializable routes, NavHost, composable<Route>, nested naviga |
|
|
195
200
|
| [`compose-testing`](./external/compose-testing/) | `external` | Test Jetpack Compose UI with createComposeRule, semantic matchers (onNodeWithText, onNodeWithTag, onNodeWithContentDescription), actions (pe |
|
|
@@ -255,6 +260,7 @@ Source layout is logical grouping only - skill discovery at runtime is unchang
|
|
|
255
260
|
| [`localization-reuse-map`](./external/localization-reuse-map/) | `external` | >- |
|
|
256
261
|
| [`pdfkit`](./external/pdfkit/) | `external` | Display and manipulate PDF documents using PDFKit. Use when embedding PDFView to show PDF files, creating or modifying PDFDocument instances |
|
|
257
262
|
| [`search-first`](./external/search-first/) | `external` | \| |
|
|
263
|
+
| [`security-review`](./external/security-review/) | `external` | Run a defensive, static security review of a diff or a repo: build a run-scoped threat model, find vulnerabilities joined to OWASP + CWE wit |
|
|
258
264
|
| [`signal-community`](./external/signal-community/) | `external` | Check whether other people hit the same problem, via Stack Overflow and Hacker News (keyless) plus opt-in Reddit and X. Use when a bug repor |
|
|
259
265
|
| [`skill-creator`](./external/skill-creator/) | `external` | Author and refine skills for a plugin/toolkit efficiently. Use when creating a new skill, trimming or splitting an existing one, writing a s |
|
|
260
266
|
| [`spm-build-analysis`](./external/spm-build-analysis/) | `external` | Analyze Swift Package Manager dependencies, package plugins, module variants, and CI-oriented build overhead that slow Xcode builds. Use whe |
|
|
@@ -12,10 +12,12 @@ and Jetpack libraries from 2024-2025.
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
|
14
14
|
- [Multi-Module Structure](#multi-module-structure)
|
|
15
|
+
- [Feature api/impl Split](#feature-apiimpl-split)
|
|
15
16
|
- [Domain Layer](#domain-layer)
|
|
16
17
|
- [Data Layer](#data-layer)
|
|
17
18
|
- [Presentation Layer](#presentation-layer)
|
|
18
19
|
- [Hilt Dependency Injection](#hilt-dependency-injection)
|
|
20
|
+
- [Cross-Module Navigation Graph Aggregation](#cross-module-navigation-graph-aggregation)
|
|
19
21
|
- [Mapper Pattern](#mapper-pattern)
|
|
20
22
|
- [Do's and Don'ts](#dos-and-donts)
|
|
21
23
|
- [Troubleshooting](#troubleshooting)
|
|
@@ -77,6 +79,33 @@ include(":feature:search")
|
|
|
77
79
|
include(":feature:detail")
|
|
78
80
|
```
|
|
79
81
|
|
|
82
|
+
## Feature api/impl Split
|
|
83
|
+
|
|
84
|
+
For features that navigate to each other, split each one into two modules:
|
|
85
|
+
|
|
86
|
+
- `:feature:x:api` -- only the cross-feature contract: `@Serializable` route
|
|
87
|
+
definitions and the entry-param models a caller needs to launch the feature.
|
|
88
|
+
- `:feature:x:impl` -- screens, ViewModels, UI state, and DI wiring.
|
|
89
|
+
|
|
90
|
+
A feature depends only on the `:api` of the features it navigates to, never
|
|
91
|
+
their `:impl`:
|
|
92
|
+
|
|
93
|
+
```kotlin
|
|
94
|
+
// feature:home:impl/build.gradle.kts
|
|
95
|
+
dependencies {
|
|
96
|
+
implementation(project(":feature:home:api"))
|
|
97
|
+
implementation(project(":feature:detail:api")) // route + entry params only
|
|
98
|
+
// NEVER depend on :feature:detail:impl
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Because no `impl` module can see another `impl`, they stay mutually invisible,
|
|
103
|
+
compile in parallel, and cannot form dependency cycles. The `:app` module wires
|
|
104
|
+
them together (see
|
|
105
|
+
[Cross-Module Navigation Graph Aggregation](#cross-module-navigation-graph-aggregation)).
|
|
106
|
+
Full module layout and route contract:
|
|
107
|
+
[references/patterns.md#feature-apiimpl-split](references/patterns.md#feature-apiimpl-split).
|
|
108
|
+
|
|
80
109
|
## Domain Layer
|
|
81
110
|
|
|
82
111
|
The domain layer contains business logic with zero Android dependencies. It
|
|
@@ -231,6 +260,11 @@ sealed interface HomeUiState {
|
|
|
231
260
|
}
|
|
232
261
|
```
|
|
233
262
|
|
|
263
|
+
For the reusable base ViewModel seam these screen ViewModels extend -- immutable
|
|
264
|
+
state as `StateFlow`, one-shot events, a single intent entry point, and
|
|
265
|
+
centralized use-case execution -- see the `android-mvi-viewmodel` skill. Do not
|
|
266
|
+
reimplement that base class here.
|
|
267
|
+
|
|
234
268
|
## Hilt Dependency Injection
|
|
235
269
|
|
|
236
270
|
### Application Setup
|
|
@@ -324,6 +358,36 @@ abstract class DispatcherModule {
|
|
|
324
358
|
}
|
|
325
359
|
```
|
|
326
360
|
|
|
361
|
+
## Cross-Module Navigation Graph Aggregation
|
|
362
|
+
|
|
363
|
+
Each `:impl` module contributes its destinations to a shared `Set<NavGraph>` via
|
|
364
|
+
Hilt multibinding; the host registers the whole set into one `NavHost`. The
|
|
365
|
+
`:app` module never depends on any `:impl`.
|
|
366
|
+
|
|
367
|
+
```kotlin
|
|
368
|
+
fun interface NavGraph {
|
|
369
|
+
fun NavGraphBuilder.build(navController: NavHostController)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// In each :impl module
|
|
373
|
+
@Module
|
|
374
|
+
@InstallIn(SingletonComponent::class)
|
|
375
|
+
abstract class HomeNavModule {
|
|
376
|
+
@Binds
|
|
377
|
+
@IntoSet
|
|
378
|
+
abstract fun bindHomeNavGraph(impl: HomeNavGraph): NavGraph
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// In :app -- register every contributed graph, naming no feature
|
|
382
|
+
NavHost(navController, startDestination = HomeRoute) {
|
|
383
|
+
navGraphs.forEach { graph -> with(graph) { build(navController) } }
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
This wires many feature modules into a single `NavHost` without the app module
|
|
388
|
+
depending on every `impl`. Full host injection and per-feature contribution:
|
|
389
|
+
[references/patterns.md#cross-module-navigation-graph-aggregation](references/patterns.md#cross-module-navigation-graph-aggregation).
|
|
390
|
+
|
|
327
391
|
## Mapper Pattern
|
|
328
392
|
|
|
329
393
|
Mappers isolate layer boundaries. Each layer has its own model type.
|
|
@@ -372,9 +436,13 @@ class FlightMapper @Inject constructor() {
|
|
|
372
436
|
- Define a `DispatcherProvider` interface for testable coroutine dispatchers
|
|
373
437
|
- One use case per business operation
|
|
374
438
|
- Use `sealed interface` for UI state to guarantee exhaustive `when` handling
|
|
439
|
+
- Split each feature into `:api` (routes + entry params) and `:impl` (screens, ViewModels, DI)
|
|
440
|
+
- Aggregate feature nav graphs into the host with Hilt `@IntoSet` multibinding
|
|
375
441
|
|
|
376
442
|
### Don'ts
|
|
377
443
|
- Do not let feature modules depend on each other directly
|
|
444
|
+
- Do not depend on another feature's `:impl` module -- depend on its `:api` only
|
|
445
|
+
- Do not make the `:app` module reference feature `impl` modules in code
|
|
378
446
|
- Do not put Android framework classes in the domain layer
|
|
379
447
|
- Do not expose `MutableStateFlow` from ViewModels (expose read-only `StateFlow`)
|
|
380
448
|
- Do not inject `Activity` or `Fragment` into ViewModels
|
|
@@ -402,6 +470,9 @@ class FlightMapper @Inject constructor() {
|
|
|
402
470
|
- [ ] UI state is a `sealed interface` with Loading/Success/Error
|
|
403
471
|
- [ ] Hilt modules use `@Binds` for interfaces, `@Provides` for instances
|
|
404
472
|
- [ ] Feature modules do not depend on each other
|
|
473
|
+
- [ ] Each feature is split into `:api` (routes/entry params) and `:impl` (screens/ViewModels/DI)
|
|
474
|
+
- [ ] Feature `impl` modules depend only on other features' `api`, never their `impl`
|
|
475
|
+
- [ ] Feature nav graphs aggregate into the host via Hilt `@IntoSet` multibinding
|
|
405
476
|
- [ ] Mappers separate DTO/Entity/Domain models
|
|
406
477
|
- [ ] Coroutine dispatchers are injectable via `DispatcherProvider`
|
|
407
478
|
- [ ] No `GlobalScope` usage; all coroutines use structured concurrency
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Android Clean Architecture -- Reference Patterns
|
|
2
|
+
|
|
3
|
+
Full code for the patterns summarized in `SKILL.md`. Load the section you need
|
|
4
|
+
when implementing that specific structure.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Feature api/impl Split](#feature-apiimpl-split)
|
|
9
|
+
- [Cross-Module Navigation Graph Aggregation](#cross-module-navigation-graph-aggregation)
|
|
10
|
+
|
|
11
|
+
## Feature api/impl Split
|
|
12
|
+
|
|
13
|
+
Split every feature into an `:api` module (the cross-feature contract) and an
|
|
14
|
+
`:impl` module (everything private to the feature).
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
:feature:home:api # Serializable routes + entry-param models only
|
|
18
|
+
:feature:home:impl # Screens, ViewModels, UI state, DI
|
|
19
|
+
:feature:search:api
|
|
20
|
+
:feature:search:impl
|
|
21
|
+
:feature:detail:api
|
|
22
|
+
:feature:detail:impl
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### The api module holds only the contract
|
|
26
|
+
|
|
27
|
+
```kotlin
|
|
28
|
+
// :feature:detail:api
|
|
29
|
+
@Serializable
|
|
30
|
+
data class DetailRoute(val id: String)
|
|
31
|
+
|
|
32
|
+
@Serializable
|
|
33
|
+
data class DetailEntryParams(val id: String, val source: String)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No screens, no ViewModels, no Compose dependency -- just the route and the
|
|
37
|
+
models a caller needs to launch the feature.
|
|
38
|
+
|
|
39
|
+
### The impl module holds everything private
|
|
40
|
+
|
|
41
|
+
```kotlin
|
|
42
|
+
// :feature:detail:impl -- not visible to any other feature
|
|
43
|
+
@HiltViewModel
|
|
44
|
+
class DetailViewModel @Inject constructor(
|
|
45
|
+
savedStateHandle: SavedStateHandle,
|
|
46
|
+
private val getDetailUseCase: GetDetailUseCase,
|
|
47
|
+
) : ViewModel() {
|
|
48
|
+
private val route = savedStateHandle.toRoute<DetailRoute>()
|
|
49
|
+
// ... state, intents, events
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Composable
|
|
53
|
+
internal fun DetailScreen(viewModel: DetailViewModel = hiltViewModel()) { /* ... */ }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Dependency direction
|
|
57
|
+
|
|
58
|
+
```kotlin
|
|
59
|
+
// feature:home:impl/build.gradle.kts
|
|
60
|
+
dependencies {
|
|
61
|
+
implementation(project(":feature:home:api"))
|
|
62
|
+
implementation(project(":feature:detail:api")) // route + entry params only
|
|
63
|
+
implementation(project(":core:model"))
|
|
64
|
+
implementation(project(":core:ui"))
|
|
65
|
+
// NEVER implementation(project(":feature:detail:impl"))
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Because no `impl` module can see another `impl`, the graph of feature modules is
|
|
70
|
+
acyclic by construction, the modules compile in parallel, and a caller can only
|
|
71
|
+
couple to a feature through its published route and entry params.
|
|
72
|
+
|
|
73
|
+
## Cross-Module Navigation Graph Aggregation
|
|
74
|
+
|
|
75
|
+
Each `:impl` module contributes its destinations to a shared `Set<NavGraph>` via
|
|
76
|
+
Hilt multibinding. The host module (`:app`) injects the full set and registers
|
|
77
|
+
every contribution into one `NavHost`, without depending on any `:impl`.
|
|
78
|
+
|
|
79
|
+
### The contribution contract
|
|
80
|
+
|
|
81
|
+
```kotlin
|
|
82
|
+
// :core:ui (or a :core:navigation module)
|
|
83
|
+
fun interface NavGraph {
|
|
84
|
+
fun NavGraphBuilder.build(navController: NavHostController)
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Each feature contributes its graph
|
|
89
|
+
|
|
90
|
+
```kotlin
|
|
91
|
+
// :feature:home:impl
|
|
92
|
+
class HomeNavGraph @Inject constructor() : NavGraph {
|
|
93
|
+
override fun NavGraphBuilder.build(navController: NavHostController) {
|
|
94
|
+
composable<HomeRoute> {
|
|
95
|
+
HomeScreen()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@Module
|
|
101
|
+
@InstallIn(SingletonComponent::class)
|
|
102
|
+
abstract class HomeNavModule {
|
|
103
|
+
@Binds
|
|
104
|
+
@IntoSet
|
|
105
|
+
abstract fun bindHomeNavGraph(impl: HomeNavGraph): NavGraph
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### The host registers the whole set
|
|
110
|
+
|
|
111
|
+
```kotlin
|
|
112
|
+
// :app
|
|
113
|
+
@Composable
|
|
114
|
+
fun AppNavHost(
|
|
115
|
+
navGraphs: Set<@JvmSuppressWildcards NavGraph>,
|
|
116
|
+
navController: NavHostController = rememberNavController(),
|
|
117
|
+
startDestination: Any = HomeRoute,
|
|
118
|
+
) {
|
|
119
|
+
NavHost(navController = navController, startDestination = startDestination) {
|
|
120
|
+
navGraphs.forEach { graph -> with(graph) { build(navController) } }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Injecting the set into the composable is done with a Hilt entry point or by
|
|
126
|
+
passing it from an `@AndroidEntryPoint` activity:
|
|
127
|
+
|
|
128
|
+
```kotlin
|
|
129
|
+
@AndroidEntryPoint
|
|
130
|
+
class MainActivity : ComponentActivity() {
|
|
131
|
+
@Inject lateinit var navGraphs: Set<@JvmSuppressWildcards NavGraph>
|
|
132
|
+
|
|
133
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
134
|
+
super.onCreate(savedInstanceState)
|
|
135
|
+
setContent { AppNavHost(navGraphs = navGraphs) }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Adding a feature to the app is now: add its `:impl` to the `:app` runtime
|
|
141
|
+
classpath and its `@IntoSet` binding registers automatically. The `:app` module
|
|
142
|
+
never names the feature in code.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: android-build-quality-gates
|
|
3
|
+
description: "Encode Android module and quality rules as build-failing Gradle tasks: layer-dependency checks, coverage floors, a Compose-stability gate, lint-on-diff and screenshot validation. Use when a multi-module app needs enforceable boundaries and quality gates in the build, not in review."
|
|
4
|
+
risk: safe
|
|
5
|
+
source: multi-agent-pipeline
|
|
6
|
+
date_added: "2026-09-21"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Android Build Quality Gates
|
|
10
|
+
|
|
11
|
+
A convention holds only while everyone remembers it. A gate holds because the
|
|
12
|
+
build turns red without it. This skill covers the quality rules worth promoting
|
|
13
|
+
from review comments into Gradle tasks that fail: layer boundaries, coverage
|
|
14
|
+
floors, Compose stability, lint on changed files, and screenshot validation.
|
|
15
|
+
|
|
16
|
+
Targets AGP 8.x, Gradle 8.x with the configuration cache, Kover 0.8+, and the
|
|
17
|
+
Compose compiler Gradle plugin (Kotlin 2.0+). Copy-ready, full-length code for
|
|
18
|
+
every gate lives in [references/patterns.md](references/patterns.md); this file
|
|
19
|
+
carries the decisions and short snippets.
|
|
20
|
+
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- [Where gates live](#where-gates-live)
|
|
24
|
+
- [Layer-dependency gate](#layer-dependency-gate)
|
|
25
|
+
- [Coverage floor with Kover](#coverage-floor-with-kover)
|
|
26
|
+
- [Compose-stability gate](#compose-stability-gate)
|
|
27
|
+
- [Lint on diff](#lint-on-diff)
|
|
28
|
+
- [Screenshot validation task](#screenshot-validation-task)
|
|
29
|
+
- [Ratchet, do not wall](#ratchet-do-not-wall)
|
|
30
|
+
- [Wiring gates into check](#wiring-gates-into-check)
|
|
31
|
+
- [Do's and Don'ts](#dos-and-donts)
|
|
32
|
+
- [Review Checklist](#review-checklist)
|
|
33
|
+
|
|
34
|
+
## Where gates live
|
|
35
|
+
|
|
36
|
+
Author gates once in a convention plugin under `build-logic`, then apply that
|
|
37
|
+
plugin from each module. A gate copied into twenty `build.gradle.kts` files
|
|
38
|
+
drifts; a gate in one convention plugin is a single source of truth. See
|
|
39
|
+
`gradle-kotlin-dsl` for how the `build-logic` module and version catalog are
|
|
40
|
+
set up; this skill only adds tasks inside it.
|
|
41
|
+
|
|
42
|
+
Every gate follows the same three-part shape:
|
|
43
|
+
|
|
44
|
+
1. A task (or a `verify`/`check`-typed configuration) that computes a fact about
|
|
45
|
+
the module: its dependency set, its coverage ratio, its unskippable
|
|
46
|
+
composables.
|
|
47
|
+
2. An assertion that throws `GradleException` when the fact violates the rule.
|
|
48
|
+
3. A wire into the `check` lifecycle (or a `finalizedBy` on an existing task) so
|
|
49
|
+
it runs without anyone remembering to call it.
|
|
50
|
+
|
|
51
|
+
Keep the assertion message actionable: name the offending dependency, the
|
|
52
|
+
current versus required number, the exact composable. A gate that fails with
|
|
53
|
+
"verification failed" gets suppressed; one that says "module `feature-x` depends
|
|
54
|
+
on `data-y`, which is not in its allowed set" gets fixed.
|
|
55
|
+
|
|
56
|
+
## Layer-dependency gate
|
|
57
|
+
|
|
58
|
+
Clean layering (feature -> domain -> data, never feature -> data directly, never
|
|
59
|
+
domain -> feature) is the rule most often stated and least often enforced. Encode
|
|
60
|
+
it by walking a module's resolved project dependencies and asserting each is in
|
|
61
|
+
an allowed set for that module's layer.
|
|
62
|
+
|
|
63
|
+
Register a task that reads the `implementation`/`api` configurations, keeps only
|
|
64
|
+
`ProjectDependency` entries, and compares their paths against an allowlist:
|
|
65
|
+
|
|
66
|
+
```kotlin
|
|
67
|
+
val checkLayerDependencies by tasks.registering {
|
|
68
|
+
group = "verification"
|
|
69
|
+
val layer = project.moduleLayer()
|
|
70
|
+
val projectDeps = configurations
|
|
71
|
+
.matching { it.name in setOf("implementation", "api") }
|
|
72
|
+
.flatMap { it.dependencies.withType<ProjectDependency>() }
|
|
73
|
+
.map { it.path }
|
|
74
|
+
doLast {
|
|
75
|
+
val forbidden = projectDeps.filterNot { layer.allows(it) }
|
|
76
|
+
if (forbidden.isNotEmpty()) {
|
|
77
|
+
throw GradleException(
|
|
78
|
+
"$path ($layer) declares forbidden dependencies: $forbidden"
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
tasks.named("check").configure { dependsOn(checkLayerDependencies) }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Derive the layer from the module path or a convention (`feature-*`, `domain-*`,
|
|
87
|
+
`data-*`) rather than a hand-maintained map, so a new module is governed the day
|
|
88
|
+
it is created. The full version, including the layer enum, its `allows` matrix,
|
|
89
|
+
and configuration-cache-safe capture of dependency paths, is in
|
|
90
|
+
[references/patterns.md#layer-dependency-gate](references/patterns.md#layer-dependency-gate).
|
|
91
|
+
|
|
92
|
+
Read dependency paths at configuration time into a local (as above), not inside
|
|
93
|
+
`doLast` off the live `configurations` object, so the task stays compatible with
|
|
94
|
+
the configuration cache.
|
|
95
|
+
|
|
96
|
+
## Coverage floor with Kover
|
|
97
|
+
|
|
98
|
+
A coverage number nobody enforces only decorates a report. Kover's verification
|
|
99
|
+
rule turns it into a gate: below the floor, the build fails.
|
|
100
|
+
|
|
101
|
+
Apply `org.jetbrains.kotlinx.kover` and declare a bound:
|
|
102
|
+
|
|
103
|
+
```kotlin
|
|
104
|
+
kover {
|
|
105
|
+
reports {
|
|
106
|
+
verify {
|
|
107
|
+
rule("Line coverage floor") {
|
|
108
|
+
minBound(60)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Split instrumentation from aggregation. Each module applies the Kover plugin so
|
|
116
|
+
its own classes are instrumented; a single aggregation module (or the root)
|
|
117
|
+
depends on every measured module and owns the `verify` rule over the merged
|
|
118
|
+
report. Verifying per module produces noisy, uneven thresholds; verifying the
|
|
119
|
+
aggregate gives one honest project number. The aggregation wiring and per-module
|
|
120
|
+
exclusion of generated code (`*_Factory`, `*Args`, `BuildConfig`, Hilt
|
|
121
|
+
components) are in
|
|
122
|
+
[references/patterns.md#coverage-floor-with-kover](references/patterns.md#coverage-floor-with-kover).
|
|
123
|
+
|
|
124
|
+
Gate the floor behind a property so local `assembleDebug` stays fast and only CI
|
|
125
|
+
(or an explicit run) pays for instrumented coverage:
|
|
126
|
+
|
|
127
|
+
```kotlin
|
|
128
|
+
tasks.named("check").configure {
|
|
129
|
+
if (providers.gradleProperty("enableCoverageVerification").isPresent) {
|
|
130
|
+
dependsOn("koverVerify")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Run it in CI with `-PenableCoverageVerification`. Set the floor to the current
|
|
136
|
+
measured value, not an aspirational one; see [Ratchet, do not wall](#ratchet-do-not-wall).
|
|
137
|
+
|
|
138
|
+
## Compose-stability gate
|
|
139
|
+
|
|
140
|
+
Unstable parameters silently defeat recomposition skipping and cause jank that
|
|
141
|
+
no unit test catches. The Compose compiler can emit a stability report; parse it
|
|
142
|
+
and fail when a composable that should skip cannot.
|
|
143
|
+
|
|
144
|
+
First point the compiler's metrics and reports at a build directory via the
|
|
145
|
+
Compose compiler Gradle plugin:
|
|
146
|
+
|
|
147
|
+
```kotlin
|
|
148
|
+
composeCompiler {
|
|
149
|
+
val dir = layout.buildDirectory.dir("compose-metrics")
|
|
150
|
+
metricsDestination.set(dir)
|
|
151
|
+
reportsDestination.set(dir)
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This produces `*-composables.txt` per module. Register a task that parses those
|
|
156
|
+
files, collects every `restartable` composable marked `skippable` as its
|
|
157
|
+
negation (i.e. the `restartable` but NOT `skippable` ones), and fails on any that
|
|
158
|
+
is not in a per-module ignore-list. Finalize each Kotlin compile with it so the
|
|
159
|
+
report is always fresh:
|
|
160
|
+
|
|
161
|
+
```kotlin
|
|
162
|
+
val checkComposeStability by tasks.registering(ComposeStabilityCheck::class) {
|
|
163
|
+
reportDir.set(layout.buildDirectory.dir("compose-metrics"))
|
|
164
|
+
ignoreFile.set(layout.projectDirectory.file("compose-stability-ignore.txt"))
|
|
165
|
+
}
|
|
166
|
+
tasks.withType<KotlinCompile>().configureEach {
|
|
167
|
+
finalizedBy(checkComposeStability)
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The ignore-list file holds fully-qualified composable names that are accepted
|
|
172
|
+
exceptions (a composable that takes a third-party unstable type you cannot
|
|
173
|
+
annotate). Each line is one accepted composable; anything unskippable and not
|
|
174
|
+
listed fails the build. The full `ComposeStabilityCheck` task with the report
|
|
175
|
+
parser and ignore-list matching is in
|
|
176
|
+
[references/patterns.md#compose-stability-gate](references/patterns.md#compose-stability-gate).
|
|
177
|
+
|
|
178
|
+
The report is only emitted when the compiler runs, which is why the check is
|
|
179
|
+
`finalizedBy` the compile rather than a standalone task: a cached compile with no
|
|
180
|
+
report means nothing changed, and the check reads the last report.
|
|
181
|
+
|
|
182
|
+
## Lint on diff
|
|
183
|
+
|
|
184
|
+
Full lint across a large multi-module project is minutes of wall time; running it
|
|
185
|
+
on every commit trains people to skip the hook. Split it: a fast pre-commit gate
|
|
186
|
+
over only the changed files, and full analysis in CI.
|
|
187
|
+
|
|
188
|
+
At commit time, feed the static analyzer (detekt or Android Lint) the diff:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
CHANGED=$(git diff --cached --name-only --diff-filter=d -- '*.kt' '*.kts')
|
|
192
|
+
[ -z "$CHANGED" ] && exit 0
|
|
193
|
+
./gradlew detektDiff -PdetektInput="$CHANGED"
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Wire `detektDiff` to accept a file list and set the detekt `source` to just those
|
|
197
|
+
paths, so the analyzer never walks the whole tree at commit time. In CI, run the
|
|
198
|
+
full `detekt` / `lint` task with the baseline so nothing escapes. The pre-commit
|
|
199
|
+
hook and the `detektDiff` task definition are in
|
|
200
|
+
[references/patterns.md#lint-on-diff](references/patterns.md#lint-on-diff).
|
|
201
|
+
|
|
202
|
+
Keep the two consistent: the same ruleset and the same baseline file drive both
|
|
203
|
+
the diff run and the full run, so a commit that passes the hook does not surprise
|
|
204
|
+
the author in CI.
|
|
205
|
+
|
|
206
|
+
## Screenshot validation task
|
|
207
|
+
|
|
208
|
+
Screenshot tests are only a gate when a mismatch fails the build. Author the
|
|
209
|
+
tests with the mechanics in `compose-testing`; here, register the verification
|
|
210
|
+
task and put it on the `check` path.
|
|
211
|
+
|
|
212
|
+
Expose a single umbrella task so CI and local calls agree:
|
|
213
|
+
|
|
214
|
+
```kotlin
|
|
215
|
+
tasks.register("verifyScreenshots") {
|
|
216
|
+
group = "verification"
|
|
217
|
+
dependsOn(tasks.matching { it.name == "verifyRoborazziDebug" })
|
|
218
|
+
}
|
|
219
|
+
tasks.named("check").configure { dependsOn("verifyScreenshots") }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Record and verify are separate entry points: `recordScreenshots` regenerates
|
|
223
|
+
golden images (run deliberately, reviewed in the diff), `verifyScreenshots`
|
|
224
|
+
compares and fails. Never let `verify` fall back to recording on a missing
|
|
225
|
+
golden, or the gate passes on the very case it exists to catch. The record/verify
|
|
226
|
+
task pair and the CI upload of diff images on failure are in
|
|
227
|
+
[references/patterns.md#screenshot-validation-task](references/patterns.md#screenshot-validation-task).
|
|
228
|
+
|
|
229
|
+
## Ratchet, do not wall
|
|
230
|
+
|
|
231
|
+
The failure mode of every gate above is the same: set the bar higher than the
|
|
232
|
+
codebase currently clears, and the team disables the gate instead of meeting it.
|
|
233
|
+
A gate that is always red is worse than no gate, because it also hides the
|
|
234
|
+
regressions it was meant to catch.
|
|
235
|
+
|
|
236
|
+
Seed each gate at the current state and let it only tighten:
|
|
237
|
+
|
|
238
|
+
- Coverage: set `minBound` to the value measured today, then raise it in small
|
|
239
|
+
steps as tests land. Never open at a round aspirational number.
|
|
240
|
+
- Compose stability: seed the ignore-list with every composable currently
|
|
241
|
+
unskippable, so the gate passes on day one and only new offenders fail. Shrink
|
|
242
|
+
the list over time; adding to it needs review.
|
|
243
|
+
- Lint: generate a baseline of existing findings; the gate fails only on new
|
|
244
|
+
ones. Regenerating the baseline to bury a fresh finding is a review smell.
|
|
245
|
+
- Layers: if a forbidden edge exists today, either fix it before turning the gate
|
|
246
|
+
on, or record it as a temporary allowed exception with a tracking reference,
|
|
247
|
+
never widen the whole allow-matrix.
|
|
248
|
+
|
|
249
|
+
A ceiling that only moves down (ignore-lists, baselines) and a floor that only
|
|
250
|
+
moves up (coverage) is the shape that survives contact with a real team.
|
|
251
|
+
|
|
252
|
+
## Wiring gates into check
|
|
253
|
+
|
|
254
|
+
Gates that are not on a lifecycle path do not run. Attach each to `check` (or a
|
|
255
|
+
custom `qualityGates` aggregate task that `check` depends on) so a single
|
|
256
|
+
`./gradlew check` runs every gate, and CI needs no bespoke task list.
|
|
257
|
+
|
|
258
|
+
```kotlin
|
|
259
|
+
val qualityGates by tasks.registering {
|
|
260
|
+
group = "verification"
|
|
261
|
+
dependsOn(
|
|
262
|
+
"checkLayerDependencies",
|
|
263
|
+
"checkComposeStability",
|
|
264
|
+
"verifyScreenshots",
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
tasks.named("check").configure { dependsOn(qualityGates) }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Keep the expensive, environment-dependent gates (instrumented coverage, full
|
|
271
|
+
lint) behind properties so `check` stays usable locally, and turn them on in CI
|
|
272
|
+
via `-P` flags. The full convention-plugin assembly that registers all gates is
|
|
273
|
+
in [references/patterns.md#wiring-gates-into-check](references/patterns.md#wiring-gates-into-check).
|
|
274
|
+
|
|
275
|
+
## Do's and Don'ts
|
|
276
|
+
|
|
277
|
+
- Do author every gate in a `build-logic` convention plugin, applied per module.
|
|
278
|
+
- Do make failure messages name the offender, the actual value, and the required
|
|
279
|
+
value.
|
|
280
|
+
- Do capture configuration values (dependency paths, file lists) into locals at
|
|
281
|
+
configuration time for configuration-cache compatibility.
|
|
282
|
+
- Do seed ignore-lists and baselines from the current state so the gate opens
|
|
283
|
+
green.
|
|
284
|
+
- Don't verify coverage per module; aggregate, then verify once.
|
|
285
|
+
- Don't let a screenshot `verify` task record missing goldens.
|
|
286
|
+
- Don't set a floor above what the code clears today; a permanently red gate gets
|
|
287
|
+
switched off.
|
|
288
|
+
- Don't duplicate the gate logic across module build files.
|
|
289
|
+
- Don't run full lint at commit time; run it on the diff, full in CI.
|
|
290
|
+
|
|
291
|
+
## Ownership Map
|
|
292
|
+
|
|
293
|
+
This skill owns build-failing quality-gate tasks. It does not re-explain:
|
|
294
|
+
|
|
295
|
+
- Typed `BuildConfig`, convention-plugin composition, version catalog, and
|
|
296
|
+
general build setup -> `gradle-kotlin-dsl`.
|
|
297
|
+
- Authoring Compose screenshot tests, semantic matchers, and ViewModel tests ->
|
|
298
|
+
`compose-testing` (this skill only registers the verification task).
|
|
299
|
+
- Codegen `--check` CI verification of generated design-token sources ->
|
|
300
|
+
`android-design-tokens-codegen`.
|
|
301
|
+
|
|
302
|
+
## Review Checklist
|
|
303
|
+
|
|
304
|
+
- [ ] Each gate lives in a convention plugin, not copied per module.
|
|
305
|
+
- [ ] Layer gate derives layer from convention, governs new modules automatically.
|
|
306
|
+
- [ ] Coverage uses a single aggregation report with one `verify` bound, gated by
|
|
307
|
+
a property.
|
|
308
|
+
- [ ] Compose stability report points at a build dir, check is `finalizedBy` the
|
|
309
|
+
compile, ignore-list holds accepted exceptions only.
|
|
310
|
+
- [ ] Lint runs on the diff at commit time, full with baseline in CI.
|
|
311
|
+
- [ ] Screenshot `verify` fails on missing/changed goldens, never records.
|
|
312
|
+
- [ ] Every gate is reachable from `check`; expensive ones sit behind `-P` flags.
|
|
313
|
+
- [ ] Floors seeded at current state; ignore-lists/baselines only ratchet down.
|
|
314
|
+
- [ ] Failure messages are actionable (offender + actual + required).
|