From 9c34c7288ba39512785abf5c3b6bfa15852af508 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:02:33 +0900 Subject: [PATCH 1/5] docs: add a guide for keeping JetWhale out of release builds A debug-only dependency stops being enough as soon as shared code calls startJetWhale: the release variant needs those symbols to compile, so the dependency comes back. Android variant source sets do not help a KMP commonMain, and a BuildConfig.DEBUG guard still ships every class. Document the seam instead: an app-owned interface with a no-op binding contributed from an always-present module, displaced on the debug classpath by a JetWhale-backed implementation via Metro's `@ContributesBinding` replaces. HTTP client capture is a multibinding rather than a replacement, since release simply contributes no element. The Metro semantics here were checked against 1.3.2 by compiling both directions: with replaces the graph merges, without it the build fails with Metro/DuplicateBinding naming both contributions. --- docs-site/.vitepress/config.mts | 4 + docs/guide/excluding-from-release-builds.md | 273 ++++++++++++++++++++ docs/guide/getting-started.md | 5 +- 3 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 docs/guide/excluding-from-release-builds.md diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index 96b18470..c5f8b6e5 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -78,6 +78,10 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'Network Inspector', link: '/guide/network-inspector' }, + { + text: 'Excluding from Release Builds', + link: '/guide/excluding-from-release-builds', + }, { text: 'MCP Server', link: '/guide/mcp-server' }, { text: 'Host Settings', link: '/guide/host-settings' }, { text: 'ADB Auto Port Mapping', link: '/guide/adb-auto-port-mapping' }, diff --git a/docs/guide/excluding-from-release-builds.md b/docs/guide/excluding-from-release-builds.md new file mode 100644 index 00000000..d015035d --- /dev/null +++ b/docs/guide/excluding-from-release-builds.md @@ -0,0 +1,273 @@ +# Excluding JetWhale from Release Builds + +JetWhale is a debugging tool: it opens a WebSocket to your machine, records HTTP traffic, and lets +an external process drive your app. None of that belongs in a build you ship. + +Keeping the *artifact* out is easy — `debugImplementation` on Android, or a build-flavor-specific +dependency elsewhere. Keeping the *call sites* out is the hard part: as soon as your shared code +says `startJetWhale { }` or `networkAgent.ktorClientPlugin()`, the release compilation needs those +symbols too, and the dependency comes right back. + +This page describes one way out for apps that use a DI container with contribution merging. The +examples use [Metro](https://zacsweers.github.io/metro/), whose `@ContributesBinding(replaces = …)` +expresses the idea directly, but the shape applies to any DI framework that can swap an +implementation per build variant. + +## The problem, concretely + +| Approach | Why it falls short | +|----------|--------------------| +| `debugImplementation` alone | The release variant fails to compile the moment shared code references JetWhale. | +| Android `src/debug/kotlin` | Variant source sets are an Android Gradle Plugin feature. A KMP `commonMain` — where your HTTP client and app startup usually live — has no equivalent. | +| `if (BuildConfig.DEBUG) { … }` | Compiles, but the dependency and every JetWhale class still ship in the release binary. The check is a runtime guard, not an exclusion. | + +What all three lack is a **seam**: a boundary that production code can compile against without +knowing JetWhale exists. + +## The shape of the fix + +Own the abstraction yourself. Production code depends on a small interface of your own; JetWhale +lives behind an implementation of it that only exists on the debug classpath. + +``` +:app @DependencyGraph(AppScope::class) + │ implementation(:core:debug) + │ debugImplementation(:debug-jetwhale) + │ + ├── :core:debug ← always compiled + │ interface DebugToolingInitializer + │ NoOpInitializer @ContributesBinding(AppScope::class) + │ + └── :debug-jetwhale ← debug classpath only; the only module that imports JetWhale + JetWhaleInitializer @ContributesBinding( + AppScope::class, + replaces = [NoOpInitializer::class], + ) +``` + +Both modules contribute a binding for `DebugToolingInitializer`. On the debug classpath the +JetWhale one `replaces` the no-op, so exactly one survives the merge. On the release classpath the +JetWhale module is simply absent and the no-op stands unopposed — no flags, no `expect`/`actual`, +no source set gymnastics. + +::: tip Where the switch happens +Metro merges contributions where the `@DependencyGraph` is declared, by scanning that compilation's +classpath. So the variant-specific dependency belongs on the module holding the graph — usually +your app module. +::: + +## 1. Declare the seam in production code + +```kotlin +// :core:debug — on every classpath, release included +package com.example.debug + +interface DebugToolingInitializer { + fun initialize() +} + +@ContributesBinding(AppScope::class) +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = Unit +} +``` + +That is the whole production-side surface. Nothing here knows what a debugger is — name the seam +after the role it plays in your app, not after the tool behind it. + +## 2. Contribute the JetWhale-backed implementation + +Everything below lives in `:debug-jetwhale`, the one module that may import +`com.kitakkun.jetwhale.*`. + +The Network Inspector requires the *same* `JetWhaleNetworkAgentPlugin` instance in two places — the +HTTP client and `startJetWhale { }` (see [Network Inspector](/guide/network-inspector#setup)). A +scoped binding is exactly the tool for that, so give the agents a single owner: + +```kotlin +// :debug-jetwhale +package com.example.debug.jetwhale + +@SingleIn(AppScope::class) +@Inject +class JetWhaleAgents { + val network: JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() +} +``` + +Then implement the seam: + +```kotlin +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { + startJetWhale { + connection { + host = "localhost" + port = 5080 + } + plugins { + register(agents.network) + } + } + } +} +``` + +::: warning `replaces` names the contributing class +`replaces = [NoOpInitializer::class]` — the *class that contributes* the binding, not the bound type +`DebugToolingInitializer`. Both contributions must also target the same scope, or the merge leaves +two bindings in place. +::: + +## 3. Start it + +Your app calls the seam, never JetWhale: + +```kotlin +// :app — Application.onCreate(), or the first line of main() +appGraph.debugToolingInitializer.initialize() +``` + +In a release build this is a call to an empty method, which R8 removes outright. + +## 4. Attach the Network Inspector to your HTTP client + +Startup is a single binding, so `replaces` fits. Client customization is different: there may be +zero of them, or several. That is a multibinding, and an absent contribution just means an absent +element — no `replaces` needed at all. + +Declare the seam and the empty case in production code: + +```kotlin +// :core:debug +fun interface HttpClientDecorator { + fun decorate(client: HttpClient) +} + +@ContributesTo(AppScope::class) +interface HttpClientDecoratorDeclarations { + // Release builds contribute nothing, and an empty multibinding is an error by default. + @Multibinds(allowEmpty = true) + fun httpClientDecorators(): Set +} +``` + +Apply them wherever you build the client — still production code, still JetWhale-free: + +```kotlin +// :core:network +@ContributesTo(AppScope::class) +interface NetworkBindings { + @Provides + @SingleIn(AppScope::class) + fun provideHttpClient(decorators: Set): HttpClient = + HttpClient().also { client -> decorators.forEach { it.decorate(client) } } +} +``` + +And contribute the JetWhale decorator from the debug module: + +```kotlin +// :debug-jetwhale +@ContributesIntoSet(AppScope::class) +@Inject +class JetWhaleHttpClientDecorator( + private val agents: JetWhaleAgents, +) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +This uses the `HttpSend` interceptor rather than `install(...)` because the client comes from the +graph already built. Register it once per client — `HttpSend` accepts duplicate interceptors +silently and would record every transaction twice. + +OkHttp works the same way: contribute an `Interceptor` with `@ContributesIntoSet` from the debug +module, and have the production `OkHttpClient` provider add every element of the (possibly empty) +set. + +## 5. Wire the variants in Gradle + +### Android + +```kotlin +// :app/build.gradle.kts +dependencies { + implementation(projects.core.debug) + debugImplementation(projects.debugJetwhale) +} +``` + +### Kotlin Multiplatform + +KMP has no build variants, so gate the dependency on a Gradle property: + +```kotlin +// :app/build.gradle.kts +val jetwhaleEnabled = providers.gradleProperty("jetwhale.enabled").orNull.toBoolean() + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.debug) + if (jetwhaleEnabled) implementation(projects.debugJetwhale) + } + } +} +``` + +Run your debug builds with `-Pjetwhale.enabled=true` (or set it in a local, un-committed +`gradle.properties`); release CI omits it and gets the no-op. Flipping the property changes the +compile classpath, so the graph module recompiles — expected, and cheap enough for a switch you +throw once per session. + +If you would rather not thread a property through the build, the alternative is two thin entry-point +modules — `:app-debug` and `:app-release` — each declaring its own graph and depending on the +appropriate set of modules. + +## Verify it + +The point of all this is a release binary with no trace of JetWhale, so check the classpath rather +than trusting the wiring: + +```shell +# Android +./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep jetwhale +``` + +No output means no JetWhale — no classes for R8 to process, no keep rules to write, and no way for +a stray `startJetWhale` call to reach production. + +## Pitfalls + +- **The no-op must live in an always-present module.** If it sits next to the JetWhale + implementation, release builds lose both and the graph fails to resolve + `DebugToolingInitializer`. +- **Forgetting `replaces` fails loudly.** The debug build stops at compile time with + `Metro/DuplicateBinding`, naming both contributions. That is the safety net working — it cannot + silently pick one. +- **Keep the seam interface out of the debug module.** Production code has to compile against it. +- **Don't let JetWhale types leak into the seam.** The moment `DebugToolingInitializer` mentions + `JetWhaleSession` or `JetWhaleNetworkAgentPlugin` in its signature, production code needs the + dependency again. +- **One agent instance, shared.** `@SingleIn(AppScope::class)` on `JetWhaleAgents` is what + guarantees the plugin registered with `startJetWhale { }` is the one installed into the HTTP + client. Two instances means the host shows no traffic. + +## Other DI frameworks + +The mechanism differs, the shape does not: + +- **Anvil / kotlin-inject-anvil** — `@ContributesBinding(replaces = [...])` carries the same + meaning; the module layout above transfers unchanged. +- **Plain Dagger/Hilt** — no contribution merging, so provide the seam from a `@Module` that exists + once per variant (`src/debug` and `src/release`, or two Gradle modules). +- **Koin / manual DI** — bind `DebugToolingInitializer` in a variant-specific module and let the + release variant bind the no-op. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index f5a38005..b302eb38 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -55,7 +55,10 @@ and may break (especially around `inline` functions) with future releases. Prefe ::: tip Only add JetWhale to debug builds (e.g. `debugImplementation` on Android, or your own build-flavor -wiring) — it is a debugging tool and should not ship in release builds. +wiring) — it is a debugging tool and should not ship in release builds. Once shared code calls +`startJetWhale { }`, a debug-only dependency stops being enough on its own; see +[Excluding from Release Builds](/guide/excluding-from-release-builds) for a DI-based seam that keeps +release builds free of JetWhale entirely. ::: ## 3. Start JetWhale in your app From 6b8fcad8a90656d3b682c7eb7244ab2c2d55c4ac Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:02:40 +0900 Subject: [PATCH 2/5] feat(skills): add /jetwhale:integrate for wiring JetWhale into an app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QA skill serves plugin authors inside this repository; nothing served the far larger group adding JetWhale to an app they want to debug. That task is decided by two things the guide cannot assume — how the project splits debug from release, and which DI framework it uses — so the skill surveys both before writing anything, and prefers riding an existing debug-only module or initializer abstraction over inventing a new seam. Wiring is routed per framework: metro.md carries the verified detail, with anvil.md, dagger-hilt.md, koin.md and no-di.md covering the rest. Only Metro's semantics were compile-checked here, so the others tell the agent to confirm annotation parameters against the project's own versions. Verification targets the release classpath rather than the wiring, since a graph that compiles proves nothing about what ships. --- .claude-plugin/marketplace.json | 6 +- plugins/jetwhale/.claude-plugin/plugin.json | 4 +- plugins/jetwhale/README.md | 25 +-- plugins/jetwhale/skills/integrate/SKILL.md | 169 ++++++++++++++++++ .../skills/integrate/references/anvil.md | 72 ++++++++ .../integrate/references/dagger-hilt.md | 118 ++++++++++++ .../skills/integrate/references/koin.md | 106 +++++++++++ .../skills/integrate/references/metro.md | 166 +++++++++++++++++ .../skills/integrate/references/no-di.md | 120 +++++++++++++ 9 files changed, 771 insertions(+), 15 deletions(-) create mode 100644 plugins/jetwhale/skills/integrate/SKILL.md create mode 100644 plugins/jetwhale/skills/integrate/references/anvil.md create mode 100644 plugins/jetwhale/skills/integrate/references/dagger-hilt.md create mode 100644 plugins/jetwhale/skills/integrate/references/koin.md create mode 100644 plugins/jetwhale/skills/integrate/references/metro.md create mode 100644 plugins/jetwhale/skills/integrate/references/no-di.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ba0078e0..41382f55 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "jetwhale", - "description": "Claude Code skills for developing plugins for JetWhale, the Compose Desktop debugging tool.", + "description": "Claude Code skills for JetWhale, the Compose Desktop debugging tool — integrating it into your app, and developing plugins for it.", "owner": { "name": "kitakkun", "url": "https://github.com/kitakkun" @@ -9,8 +9,8 @@ { "name": "jetwhale", "source": "./plugins/jetwhale", - "description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.", - "version": "0.1.0", + "description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.", + "version": "0.2.0", "author": { "name": "kitakkun", "url": "https://github.com/kitakkun" diff --git a/plugins/jetwhale/.claude-plugin/plugin.json b/plugins/jetwhale/.claude-plugin/plugin.json index 61893676..604b5233 100644 --- a/plugins/jetwhale/.claude-plugin/plugin.json +++ b/plugins/jetwhale/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jetwhale", - "description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.", - "version": "0.1.0", + "description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.", + "version": "0.2.0", "author": { "name": "kitakkun", "url": "https://github.com/kitakkun" diff --git a/plugins/jetwhale/README.md b/plugins/jetwhale/README.md index cc37543f..5a123bf7 100644 --- a/plugins/jetwhale/README.md +++ b/plugins/jetwhale/README.md @@ -1,9 +1,10 @@ # JetWhale plugin for Claude Code -Skills for developing [JetWhale](https://github.com/kitakkun/JetWhale) host plugins. +Skills for using and extending [JetWhale](https://github.com/kitakkun/JetWhale). | Skill | What it covers | |---|---| +| `/jetwhale:integrate` | Adding JetWhale to an app you want to debug — surveying the build, HTTP client and DI framework, then wiring startup and traffic capture behind a seam so no JetWhale symbol reaches release builds | | `/jetwhale:plugin-qa` | Driving a host plugin's real UI through the debug tool's MCP server — screenshots, gestures, persisted state, restart restore, and a headless debuggee to drive it against | ## Install @@ -23,16 +24,20 @@ claude plugin marketplace add kitakkun/JetWhale --sparse .claude-plugin plugins Either way, installing copies just this directory into `~/.claude/plugins/cache`. -## Why the skill lives in the JetWhale repository +## Why the skills live in the JetWhale repository -A QA skill is only useful while it is true, and what it describes — MCP tool names, the QA agent's -control API, which ports the launch tasks accept — moves with the code. Keeping it here means a -change to the host and the change to its documented workflow land in the same commit, reviewed -together. A separate repository would let the two drift, and a QA guide that quietly lies is worse -than none. +A skill is only useful while it is true, and what these describe — MCP tool names, the QA agent's +control API, which ports the launch tasks accept, the published artifact coordinates — moves with +the code. Keeping them here means a change to the host and the change to its documented workflow +land in the same commit, reviewed together. A separate repository would let the two drift, and a +guide that quietly lies is worse than none. ## Requirements -The skill assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle plugin and sets -`jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and `runJetWhaleQaAgent` tasks -it drives. See the [plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs). +`/jetwhale:integrate` runs against the app you want to debug and needs nothing installed beyond +that project; it will tell you if the project's Kotlin version is too old. + +`/jetwhale:plugin-qa` assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle +plugin and sets `jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and +`runJetWhaleQaAgent` tasks it drives. See the +[plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs). diff --git a/plugins/jetwhale/skills/integrate/SKILL.md b/plugins/jetwhale/skills/integrate/SKILL.md new file mode 100644 index 00000000..57c8bd59 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/SKILL.md @@ -0,0 +1,169 @@ +--- +name: integrate +description: Add JetWhale to an app you want to debug — survey the project's build, HTTP client and DI framework, then wire startup and traffic capture behind a seam so no JetWhale symbol reaches release builds. +--- + +# Integrating JetWhale + +Adding the dependency is three lines. Adding it *without* dragging a debugging tool into the +shipped binary is the actual job, and it is decided by two things you do not control: how the +project splits debug from release, and which DI framework it uses. + +So this skill is a survey followed by a routing decision. Do not start editing until §1 and §2 are +answered — a wiring chosen for the wrong DI framework has to be undone before the right one goes +in, and the wrong dependency configuration is invisible until someone builds a release months +later. + +**The rule everything else serves:** production code must never name a JetWhale type. Not in an +import, not in a signature, not behind an `if (DEBUG)`. Anything that names JetWhale needs JetWhale +on the classpath to compile, and a dependency you cannot remove from the release compile classpath +is a dependency you ship. + +## 1. Survey the project + +Run these before deciding anything. Each answer routes a later step. + +```bash +# Targets: is this Android-only, or Kotlin Multiplatform? +grep -rlE 'kotlin\("multiplatform"\)|kotlin-multiplatform' --include=build.gradle.kts . + +# DI framework +grep -rnE 'dev\.zacsweers\.metro|com\.squareup\.anvil|lastmile\.kotlin\.inject\.anvil|com\.google\.dagger|dagger\.hilt|io\.insert-koin' \ + --include=*.kts --include=*.toml . | head + +# HTTP client +grep -rnE 'io\.ktor:ktor-client|com\.squareup\.okhttp3' --include=*.kts --include=*.toml . | head + +# An existing debug seam to ride on +grep -rn 'BuildConfig.DEBUG' --include=*.kt . | head +find . -type d -name debug -path '*/src/*' | head +grep -rlniE 'class (NoOp|Noop)[A-Za-z]*' --include=*.kt . | head +``` + +Answer these five, out loud, before continuing: + +| Question | Why it routes | +|---|---| +| Android variants, KMP, or both? | Whether `debugImplementation` exists at all (§3) | +| Which DI framework? | Which reference file to follow (§4) | +| Ktor, OkHttp, both, or neither? | Whether the Network Inspector is in scope at all | +| Is there already a debug-only module or `src/debug` source set? | Ride it instead of inventing one (§2) | +| Where does the app start — `Application.onCreate()`, `main()`, an initializer list? | Where the one call goes (§4) | + +**Kotlin version.** JetWhale needs **Kotlin 2.3+** in the consuming project; older versions fail +the build with metadata-version errors. Check it now (`grep -n 'kotlin' gradle/libs.versions.toml`) +— if the project is older, stop and say so. `-Xskip-metadata-version-check` exists but is an +unsupported escape hatch, not a plan. + +## 2. Ride an existing seam before building one + +Most apps that already carry debug-only tooling — Chucker, Flipper, LeakCanary, an internal debug +drawer — have solved this problem once. Reuse costs nothing and matches what reviewers expect. + +Look for, in order: + +1. **A debug-only Gradle module** (`:debug`, `:core:debug-tooling`, anything pulled in with + `debugImplementation`). Put the JetWhale wiring there and you are nearly done. +2. **An existing initializer abstraction** — an interface with a no-op implementation, an + `AppInitializer` list, a `Set` multibinding. Contribute one more element. +3. **`src/debug` / `src/release` source sets** holding variant-specific implementations of a shared + interface. Add your implementation to the debug side. + +Only when none of these exist do you create the seam yourself, following the reference for the +project's DI framework. + +**Do not introduce a DI framework to solve this.** A project with no seam and no container wants +the plain-interface version in `references/no-di.md`, not a new dependency in its production build. + +## 3. Add the dependencies + +```kotlin +dependencies { + // `implementation` here only because this block belongs in a debug-only module. + // In an Android app module it is `debugImplementation` — see the table below. + implementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") + // only if capturing HTTP traffic — match the app's client: + implementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-ktor:") + implementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-okhttp:") +} +``` + +Take `` from the [releases page](https://github.com/kitakkun/JetWhale/releases) — do not +guess it, and do not copy a version out of an older document. + +**Which configuration** is the whole point: + +| Project shape | How the dependency stays out of release | +|---|---| +| Android app or module | `debugImplementation`, on the module that will hold the JetWhale wiring | +| KMP, no Android variants | No variant concept exists. Gate the dependency on a Gradle property (`if (providers.gradleProperty("jetwhale.enabled").orNull.toBoolean())`), or keep two thin entry-point modules | +| A dedicated debug-only module | The module itself is added with `debugImplementation`; inside it, plain `implementation` is correct | + +If the answer is "the shared module everything depends on, with plain `implementation`", stop. +That ships JetWhale. Go back to §2. + +## 4. Wire it — follow the reference for the project's DI framework + +Each reference gives the seam, the two implementations, and the framework's mechanism for making +the debug one win. They share a shape: an app-owned interface, a no-op bound by default, and a +JetWhale-backed implementation that displaces it on the debug classpath only. + +| Detected | Read | +|---|---| +| Metro (`dev.zacsweers.metro`) | `references/metro.md` | +| Anvil, or kotlin-inject-anvil | `references/anvil.md` | +| Dagger or Hilt | `references/dagger-hilt.md` | +| Koin | `references/koin.md` | +| None | `references/no-di.md` | + +Whichever you follow, two JetWhale-side facts hold: + +- **`startJetWhale { }` is called once**, as early as the app can — `Application.onCreate()`, the + first line of `main()`, or the SwiftUI `App` init. It returns a session handle that a + connect-once app can ignore. +- **One `JetWhaleNetworkAgentPlugin` instance serves two call sites** — installed into the HTTP + client, and registered in `plugins { register(...) }`. Two instances is the classic mistake: the + app connects, the host lists the session, and no traffic ever appears. Give it a singleton + binding and inject it in both places. + +## 5. Verify — the classpath, not the wiring + +The wiring compiling proves nothing about release builds. Check the artifact: + +```bash +# Android: nothing at all should come back +./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -i jetwhale + +# Both variants must still compile — release is the one that catches a leaked reference +./gradlew assembleDebug assembleRelease # or the KMP equivalents +``` + +An empty grep and a green release build together mean the seam holds. Then confirm the debug side +actually works, because a perfectly isolated integration that never connects is the other failure: + +1. Launch the JetWhale host (default port **5080**). +2. Android only — `adb reverse tcp:5080 tcp:5080`, or enable ADB auto port mapping in the host. +3. Launch the debug build. It appears as a session in the host within a second or two. +4. If the Network Inspector is wired, make one request and watch it land. + +Nothing in the host means the agent never connected: wrong port, no port forwarding, or +`startJetWhale` not reached. Session present but no traffic means two agent instances — see §4. + +## 6. Report what you did + +State plainly: + +- which seam you rode or created, and in which module +- the dependency configuration used, and the release-classpath grep result +- whether you saw the session connect, or only that it compiles — do not imply a live check you + did not run +- anything you left unwired (e.g. OkHttp present but only Ktor wired) and why + +## Reference + +- [Excluding from Release Builds](https://kitakkun.github.io/JetWhale/guide/excluding-from-release-builds) + — the long-form version of §4, with the Metro examples in full +- [Getting Started](https://kitakkun.github.io/JetWhale/guide/getting-started) — the + `startJetWhale { }` DSL, wss, per-platform startup locations +- [Network Inspector](https://kitakkun.github.io/JetWhale/guide/network-inspector) — Ktor and + OkHttp adapters, mocking, redaction diff --git a/plugins/jetwhale/skills/integrate/references/anvil.md b/plugins/jetwhale/skills/integrate/references/anvil.md new file mode 100644 index 00000000..be46509e --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/anvil.md @@ -0,0 +1,72 @@ +# Wiring with Anvil / kotlin-inject-anvil + +Both frameworks merge contributions across the compile classpath and both spell displacement +`replaces`, so the module layout and the seam are identical to Metro's — read +[`metro.md`](metro.md) first and treat this file as the delta. The differences are annotation +packages, the injection annotation, and how multibindings are expressed. + +**Verify against the version the project actually uses.** These libraries move, and +kotlin-inject-anvil in particular has changed the shape of its multibinding support across +releases. Check the annotation's parameters in the resolved artifact before relying on them. + +## Square Anvil (Dagger-backed) + +```kotlin +// production module +interface DebugToolingInitializer { + fun initialize() +} + +@ContributesBinding(AppScope::class) +class NoOpInitializer @Inject constructor() : DebugToolingInitializer { + override fun initialize() = Unit +} +``` + +```kotlin +// debug-only module +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Singleton +class JetWhaleInitializer @Inject constructor( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { /* startJetWhale { … } */ } +} +``` + +- `@ContributesMultibinding(AppScope::class)` is the equivalent of Metro's `@ContributesIntoSet` + for the HTTP-client decorator set. +- An empty `Set` needs a `@Multibinds` declaration in a `@ContributesTo` + module in production code — Dagger errors on an undeclared empty set the same way Metro does. +- Anvil is in maintenance mode; if the project is on a recent Kotlin and considering a move, Metro + is the successor and this wiring transfers with only the annotation packages changed. + +## kotlin-inject-anvil + +Same annotation names under `software.amazon.lastmile.kotlin.inject.anvil`, with kotlin-inject's +`@Inject` (`me.tatarka.inject.annotations.Inject`) and `@SingleIn(AppScope::class)` for scoping. + +```kotlin +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +@SingleIn(AppScope::class) +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { /* startJetWhale { … } */ } +} +``` + +For the decorator set, kotlin-inject-anvil expresses multibinding contributions through +`@ContributesBinding`'s multibinding support rather than a separate annotation — confirm the exact +parameter in the project's version. If it is absent or awkward, skip the multibinding entirely: a +single `HttpClientDecorator` binding with a no-op default, displaced by `replaces` exactly like the +initializer, does the same job with one mechanism instead of two. + +## Common failure modes + +| Symptom | Cause | +|---|---| +| Duplicate binding for the seam type | `replaces` missing, or the contributions target different scopes | +| Release cannot resolve the seam | The no-op is in the debug-only module | +| Contribution silently ignored | The debug module is not on the compile classpath of the component/graph declaration — the variant dependency has to sit on the module that merges | diff --git a/plugins/jetwhale/skills/integrate/references/dagger-hilt.md b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md new file mode 100644 index 00000000..35bd8ae9 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md @@ -0,0 +1,118 @@ +# Wiring with Dagger or Hilt + +Neither merges contributions off the classpath, so there is no `replaces` to lean on. The switch is +made by **supplying the binding from a different place per variant**: one module for debug, one for +release, providing the same type. + +Android variant source sets make this cheap — `src/debug/` and `src/release/` inside a single +module, with `debugImplementation` for the JetWhale dependency. Nothing in `src/main/` ever names +JetWhale, so the release compilation never needs it. + +## Layout + +``` +:app + build.gradle.kts debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") + src/main/…/DebugToolingInitializer.kt ← the seam, always compiled + src/debug/…/DebugToolingModule.kt ← binds JetWhaleInitializer + src/release/…/DebugToolingModule.kt ← binds NoOpInitializer +``` + +Both module files must declare the **same binding key**, and Hilt requires the same +`@InstallIn` component. Keeping the file name and class name identical across the two source sets +is what makes the pair obvious to the next reader. + +## The seam + +```kotlin +// src/main +interface DebugToolingInitializer { + fun initialize() +} +``` + +## Release side + +```kotlin +// src/release +@Module +@InstallIn(SingletonComponent::class) +object DebugToolingModule { + @Provides + @Singleton + fun provideInitializer(): DebugToolingInitializer = + object : DebugToolingInitializer { + override fun initialize() = Unit + } +} +``` + +## Debug side + +```kotlin +// src/debug — the only source set that imports JetWhale +@Module +@InstallIn(SingletonComponent::class) +object DebugToolingModule { + @Provides + @Singleton + fun provideNetworkAgent(): JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() + + @Provides + @Singleton + fun provideInitializer(agent: JetWhaleNetworkAgentPlugin): DebugToolingInitializer = + DebugToolingInitializer { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(agent) } + } + } +} +``` + +`@Singleton` on the agent provider is what keeps one instance across the two call sites. + +## HTTP client capture + +Use an optional multibinding so the release side contributes nothing: + +```kotlin +// src/main — declares the (possibly empty) set +@Module +@InstallIn(SingletonComponent::class) +abstract class HttpClientDecoratorModule { + @Multibinds + abstract fun decorators(): Set +} +``` + +```kotlin +// src/debug +@Provides +@IntoSet +fun provideJetWhaleDecorator(agent: JetWhaleNetworkAgentPlugin): HttpClientDecorator = + HttpClientDecorator { client -> + client.plugin(HttpSend).intercept(agent.ktorSendInterceptor(client)) + } +``` + +The production `HttpClient` / `OkHttpClient` provider injects `Set` and applies +every element. In release the set is empty and the provider is unchanged. + +## Plain Dagger, or a KMP project + +Without Hilt, the same split applies to the component's `modules = [...]` list: declare +`DebugToolingModule` in both source sets and list it once in the component. + +KMP has no variant source sets. Put the debug module in its own Gradle module and gate the +dependency on a Gradle property, or keep two thin entry-point modules — see the KMP section of +[`metro.md`](metro.md), which is framework-independent. + +## Failure modes + +| Symptom | Cause | +|---|---| +| `DuplicateBindings` | Both source sets ended up on one compile — check that `src/debug` is not also listed in the release variant's source sets | +| `MissingBinding` in release only | The release module file is missing, or its `@InstallIn` component differs from the debug one | +| Unresolved reference to JetWhale in release | Something in `src/main` names a JetWhale type — the seam leaked | +| Session connects, no traffic | Two agent instances — `@Singleton` missing on the agent provider | diff --git a/plugins/jetwhale/skills/integrate/references/koin.md b/plugins/jetwhale/skills/integrate/references/koin.md new file mode 100644 index 00000000..dd749df1 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/koin.md @@ -0,0 +1,106 @@ +# Wiring with Koin + +Koin resolves at runtime, so nothing stops a release build from *compiling* against JetWhale — the +compiler will not catch a leak for you. The discipline has to come from the module layout: the +seam's implementation lives in a variant-specific file, and the JetWhale dependency is +variant-specific too, so a leak fails the release build at compile time rather than shipping. + +## The seam + +```kotlin +// src/main — production code +interface DebugToolingInitializer { + fun initialize() +} +``` + +## One Koin module per variant + +Declare the same `Module` value in both source sets, and load it once from `startKoin`. + +```kotlin +// src/release +val debugToolingModule = module { + single { + object : DebugToolingInitializer { + override fun initialize() = Unit + } + } +} +``` + +```kotlin +// src/debug — the only source set importing JetWhale +val debugToolingModule = module { + single { JetWhaleNetworkAgentPlugin() } + single { + val agent: JetWhaleNetworkAgentPlugin = get() + DebugToolingInitializer { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(agent) } + } + } + } +} +``` + +```kotlin +// src/main — unchanged between variants +startKoin { + modules(appModule, networkModule, debugToolingModule) +} + +get().initialize() +``` + +`single { }` — not `factory { }` — is what keeps one `JetWhaleNetworkAgentPlugin` across the HTTP +client and the session. A `factory` here produces the "session connects, no traffic" bug. + +## HTTP client capture + +Koin has no multibinding, so give the production client provider a list it can resolve in both +variants: + +```kotlin +// src/main — production +val networkModule = module { + single { HttpClient().also { client -> getAll().forEach { it.decorate(client) } } } +} +``` + +`getAll()` returns every definition of the type and is empty when none are declared, which is +exactly the release case. Declare the decorator only in `src/debug`: + +```kotlin +// src/debug +single { + val agent: JetWhaleNetworkAgentPlugin = get() + HttpClientDecorator { client -> client.plugin(HttpSend).intercept(agent.ktorSendInterceptor(client)) } +} +``` + +Check `getAll`'s behaviour in the project's Koin version before relying on it; if it is unavailable, +an explicit `getOrNull()` with a nullable single works just as well and reads +more plainly. + +## Gradle + +```kotlin +dependencies { + debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") + debugImplementation("com.kitakkun.jetwhale:jetwhale-network-inspector-agent-ktor:") +} +``` + +KMP without Android variants has no `src/debug`; put the debug Koin module in its own Gradle module +and gate the dependency on a Gradle property — see the KMP section of [`metro.md`](metro.md). + +## Failure modes + +| Symptom | Cause | +|---|---| +| `NoDefinitionFoundException` for the seam in release | The release-side module file is missing, or `debugToolingModule` was not passed to `modules(...)` | +| Release compiles but ships JetWhale | The dependency was added with `implementation`, not `debugImplementation` | +| Session connects, no traffic | `factory` instead of `single` for the agent, or two separate `single` definitions | +| Runtime crash only in release | Koin resolves at runtime — a missing definition is not a compile error. Launch the release build once before calling it done | diff --git a/plugins/jetwhale/skills/integrate/references/metro.md b/plugins/jetwhale/skills/integrate/references/metro.md new file mode 100644 index 00000000..7f555f12 --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/metro.md @@ -0,0 +1,166 @@ +# Wiring with Metro + +Metro merges contributions at the `@DependencyGraph` declaration by scanning that compilation's +classpath, and `@ContributesBinding(replaces = ...)` lets one contribution displace another. That is +exactly the seam: contribute a no-op from a module that is always present, and a JetWhale-backed +implementation from a module that only exists on the debug classpath. + +The variant-specific dependency therefore belongs on **the module that declares the graph**, usually +the app module. + +## Module layout + +``` +:app @DependencyGraph(AppScope::class) + │ implementation(:core:debug) + │ debugImplementation(:debug-jetwhale) + │ + ├── :core:debug ← every classpath + └── :debug-jetwhale ← debug classpath only; the only module importing JetWhale +``` + +## 1. The seam, in production code + +```kotlin +// :core:debug +interface DebugToolingInitializer { + fun initialize() +} + +@ContributesBinding(AppScope::class) +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = Unit +} +``` + +## 2. The JetWhale side + +```kotlin +// :debug-jetwhale +@SingleIn(AppScope::class) +@Inject +class JetWhaleAgents { + val network: JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() +} + +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +@Inject +class JetWhaleInitializer( + private val agents: JetWhaleAgents, +) : DebugToolingInitializer { + override fun initialize() { + startJetWhale { + connection { + host = "localhost" + port = 5080 + } + plugins { + register(agents.network) + } + } + } +} +``` + +`@SingleIn(AppScope::class)` on `JetWhaleAgents` is what guarantees the plugin registered with the +session is the same one installed into the HTTP client. + +## 3. The call site + +```kotlin +appGraph.debugToolingInitializer.initialize() +``` + +Add the accessor to the graph interface. In release this calls an empty method that R8 removes. + +## 4. HTTP client capture — a multibinding, not a replacement + +Startup is one binding, so `replaces` fits. Client customization may have zero contributions, which +is what a multibinding models: the debug module contributes an element, release contributes none. +No `replaces` involved. + +```kotlin +// :core:debug +fun interface HttpClientDecorator { + fun decorate(client: HttpClient) +} + +@ContributesTo(AppScope::class) +interface HttpClientDecoratorDeclarations { + // Empty multibindings are an error by default, and release contributes nothing. + @Multibinds(allowEmpty = true) + fun httpClientDecorators(): Set +} +``` + +```kotlin +// :core:network — production code, still JetWhale-free +@ContributesTo(AppScope::class) +interface NetworkBindings { + @Provides + @SingleIn(AppScope::class) + fun provideHttpClient(decorators: Set): HttpClient = + HttpClient().also { client -> decorators.forEach { it.decorate(client) } } +} +``` + +```kotlin +// :debug-jetwhale +@ContributesIntoSet(AppScope::class) +@Inject +class JetWhaleHttpClientDecorator( + private val agents: JetWhaleAgents, +) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +`HttpSend` rather than `install(...)` because the client arrives from the graph already built. +Register once per client — `HttpSend` accepts duplicate interceptors silently and records every +transaction twice. + +OkHttp is the same shape: contribute an `Interceptor` with `@ContributesIntoSet`, and have the +production `OkHttpClient` provider add every element of the (possibly empty) set as an application +interceptor. + +## Gradle + +```kotlin +// Android +dependencies { + implementation(projects.core.debug) + debugImplementation(projects.debugJetwhale) +} +``` + +```kotlin +// KMP — no variants, so gate on a property +val jetwhaleEnabled = providers.gradleProperty("jetwhale.enabled").orNull.toBoolean() + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.debug) + if (jetwhaleEnabled) implementation(projects.debugJetwhale) + } + } +} +``` + +Run debug builds with `-Pjetwhale.enabled=true`; release CI omits it. Flipping the property changes +the compile classpath, so the graph module recompiles. + +## Failure modes + +| Symptom | Cause | +|---|---| +| `Metro/DuplicateBinding` naming both implementations | `replaces` missing, or the two contributions target different scopes | +| Release build cannot resolve `DebugToolingInitializer` | The no-op lives in the debug-only module; move it to the always-present one | +| Empty-multibinding error in release | `@Multibinds(allowEmpty = true)` missing, or declared in the debug module instead of production code | +| Session connects, no traffic | Two `JetWhaleNetworkAgentPlugin` instances — check `@SingleIn` on the holder | + +`replaces` names the **contributing class** (`NoOpInitializer`), never the bound type +(`DebugToolingInitializer`). diff --git a/plugins/jetwhale/skills/integrate/references/no-di.md b/plugins/jetwhale/skills/integrate/references/no-di.md new file mode 100644 index 00000000..8833166f --- /dev/null +++ b/plugins/jetwhale/skills/integrate/references/no-di.md @@ -0,0 +1,120 @@ +# Wiring without a DI framework + +A project with no container does not need one to keep JetWhale out of release builds. The seam is a +plain interface and a variant-specific factory — roughly ten lines, no new dependency in production +code. + +Do **not** introduce a DI framework for this. Adding a container to an app that has deliberately +avoided one is a far larger change than the integration it would serve, and it is not yours to make. + +## Android — variant source sets + +```kotlin +// src/main — production code, no JetWhale anywhere +interface DebugTooling { + fun start() +} + +object DebugToolingHolder { + val instance: DebugTooling by lazy { createDebugTooling() } +} +``` + +`createDebugTooling()` is declared once per variant, with the same signature: + +```kotlin +// src/release +internal fun createDebugTooling(): DebugTooling = object : DebugTooling { + override fun start() = Unit +} +``` + +```kotlin +// src/debug — the only source set importing JetWhale +internal fun createDebugTooling(): DebugTooling = object : DebugTooling { + private val networkAgent = JetWhaleNetworkAgentPlugin() + + override fun start() { + startJetWhale { + connection { host = "localhost"; port = 5080 } + plugins { register(networkAgent) } + } + } + + // expose the agent if the HTTP client needs it — see below +} +``` + +Call it once, from `Application.onCreate()`: + +```kotlin +DebugToolingHolder.instance.start() +``` + +The Gradle side is a single line: + +```kotlin +dependencies { + debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") +} +``` + +## HTTP client capture + +The client is built in production code, so give the seam a method that can do nothing: + +```kotlin +// src/main +interface DebugTooling { + fun start() + fun decorate(client: HttpClient) // release: empty body +} +``` + +```kotlin +// src/main — where the client is built +val client = HttpClient().also { DebugToolingHolder.instance.decorate(it) } +``` + +`HttpClient` here is Ktor's type, which production code already depends on — that is what makes it +safe to name in the seam. The rule is only that **JetWhale** types stay out of it. + +The debug implementation holds the agent as a field, so `start()` and `decorate()` share one +instance. Two instances is the classic mistake: the session connects and no traffic ever appears. + +## KMP — no variant source sets + +`src/debug` is an Android Gradle Plugin feature; `expect`/`actual` splits by *platform*, not by +build type, so neither helps. Two options: + +1. **A debug-only Gradle module** holding the JetWhale implementation, with the dependency gated on + a Gradle property. Production code then needs a way to find the implementation without naming + it — a `ServiceLoader`-style lookup, or an `init` block in the debug module that registers + itself into a mutable holder in production code: + + ```kotlin + // production + object DebugToolingHolder { + var instance: DebugTooling = NoOpDebugTooling + } + ``` + + ```kotlin + // debug module, called explicitly from the debug entry point + DebugToolingHolder.instance = JetWhaleDebugTooling() + ``` + +2. **Two thin entry-point modules** — `:app-debug` and `:app-release`, each with its own `main()` + that wires what it needs. More files, but no mutable global and no reflection. + +Option 2 is usually the better fit for a Compose Multiplatform desktop app, where the entry point is +already tiny. Option 1 fits when the entry point is shared and platform-specific. + +## Failure modes + +| Symptom | Cause | +|---|---| +| Release build cannot resolve `createDebugTooling` | Only the debug source set defines it — both variants need one | +| Unresolved JetWhale reference in release | A JetWhale type reached the seam or `src/main` | +| Session connects, no traffic | `start()` and `decorate()` are using different agent instances | +| Nothing appears in the host | The holder was never touched, so `by lazy` never ran — confirm the call site is actually reached | From fc21a4b56bd594b8d475f752cee116d968a4141f Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:54:05 +0900 Subject: [PATCH 3/5] docs(skills): replace reasoned DI guidance with verified guidance Shipping a skill that tells an agent how to wire five DI frameworks, while having compiled only one of them, put the burden of being wrong on whoever ran it. Each pattern was rebuilt as a four-module project and run, so the recorded result is the binding that actually resolved. Three claims did not survive: - kotlin-inject has no `@Multibinds(allowEmpty = true)`, so an empty set fails KSP outright. The HTTP decorator has to be an ordinary binding with a no-op default, displaced by `replaces` like the initializer. - Square Anvil generates through the Kotlin compiler plugin, which runs after KSP reads source. With Dagger on KSP nothing is generated at all and the build fails much later on an unresolved DaggerAppComponent; kapt is required. - Hilt does not need the same-name module pair this previously described. `@InstallIn` is discovered from the classpath, so `@BindsOptionalOf` in src/main plus a module in src/debug is enough. The pair is only unavoidable for plain Dagger, which lists its modules explicitly. Dagger's `@Multibinds` also allows an empty set with no parameter, unlike Metro's. Android isolation was checked on the artifacts rather than the wiring: the debug-only project is absent from releaseRuntimeClasspath and its classes are absent from the release APK's dex. --- docs/guide/excluding-from-release-builds.md | 22 ++- plugins/jetwhale/skills/integrate/SKILL.md | 42 ++++- .../skills/integrate/references/anvil.md | 136 +++++++++++++---- .../integrate/references/dagger-hilt.md | 144 +++++++++++------- .../skills/integrate/references/koin.md | 9 +- .../skills/integrate/references/metro.md | 5 + .../skills/integrate/references/no-di.md | 5 + 7 files changed, 263 insertions(+), 100 deletions(-) diff --git a/docs/guide/excluding-from-release-builds.md b/docs/guide/excluding-from-release-builds.md index d015035d..4b2bbc87 100644 --- a/docs/guide/excluding-from-release-builds.md +++ b/docs/guide/excluding-from-release-builds.md @@ -263,11 +263,21 @@ a stray `startJetWhale` call to reach production. ## Other DI frameworks -The mechanism differs, the shape does not: +The seam is the same everywhere; only the mechanism for making the debug side win differs. Each of +these was checked by building and running it, not inferred from the annotations: - **Anvil / kotlin-inject-anvil** — `@ContributesBinding(replaces = [...])` carries the same - meaning; the module layout above transfers unchanged. -- **Plain Dagger/Hilt** — no contribution merging, so provide the seam from a `@Module` that exists - once per variant (`src/debug` and `src/release`, or two Gradle modules). -- **Koin / manual DI** — bind `DebugToolingInitializer` in a variant-specific module and let the - release variant bind the no-op. + meaning and the module layout transfers unchanged. Two caveats: kotlin-inject has no equivalent of + `@Multibinds(allowEmpty = true)`, so model the HTTP decorator as an ordinary binding with a no-op + default rather than a set; and Square Anvil generates through the Kotlin compiler plugin, so + Dagger has to run on kapt — under KSP nothing is generated at all. +- **Hilt** — `@InstallIn` modules are discovered from the classpath, so a `@BindsOptionalOf` + declaration in `src/main` plus a module in `src/debug` is enough. No release-side counterpart is + needed; the generated component simply binds `Optional.empty()`. +- **Plain Dagger** — `@Component(modules = [...])` names its modules, and `src/main` cannot name a + class that only exists in `src/debug`. Here you do need the same fully-qualified module in both + source sets, empty in release. Note that Dagger's `@Multibinds` allows an empty set by default — + there is no `allowEmpty` to pass. +- **Koin / manual DI** — resolution is at runtime, so nothing fails the release build for you. + Declare the JetWhale definitions only in the debug source set and reach for them with + `getOrNull` / `getAll`, which return absent and empty respectively when nothing is registered. diff --git a/plugins/jetwhale/skills/integrate/SKILL.md b/plugins/jetwhale/skills/integrate/SKILL.md index 57c8bd59..327f0a6f 100644 --- a/plugins/jetwhale/skills/integrate/SKILL.md +++ b/plugins/jetwhale/skills/integrate/SKILL.md @@ -108,13 +108,18 @@ Each reference gives the seam, the two implementations, and the framework's mech the debug one win. They share a shape: an app-owned interface, a no-op bound by default, and a JetWhale-backed implementation that displaces it on the debug classpath only. -| Detected | Read | -|---|---| -| Metro (`dev.zacsweers.metro`) | `references/metro.md` | -| Anvil, or kotlin-inject-anvil | `references/anvil.md` | -| Dagger or Hilt | `references/dagger-hilt.md` | -| Koin | `references/koin.md` | -| None | `references/no-di.md` | +| Detected | Read | The edge that framework has | +|---|---|---| +| Metro (`dev.zacsweers.metro`) | `references/metro.md` | Empty multibindings need `@Multibinds(allowEmpty = true)` | +| kotlin-inject-anvil | `references/anvil.md` | **No empty multibindings at all** — use `replaces` for every seam | +| Square Anvil | `references/anvil.md` | **Dagger must run on kapt, not KSP**, or nothing is generated | +| Hilt | `references/dagger-hilt.md` | `@InstallIn` is discovered, so the debug side is the only side | +| Plain Dagger | `references/dagger-hilt.md` | Modules are listed, so a same-name module per variant is unavoidable | +| Koin | `references/koin.md` | Runtime resolution — the compiler catches nothing | +| None | `references/no-di.md` | Same-signature factory per variant; KMP has no variants | + +Every row was verified by building and running a project, not reasoned about. See +[Verified against](#verified-against) for versions and evidence. Whichever you follow, two JetWhale-side facts hold: @@ -159,6 +164,29 @@ State plainly: did not run - anything you left unwired (e.g. OkHttp present but only Ktor wired) and why +## Verified against + +Each pattern below was built as a four-module project (`:seam`, `:tooling`, `:app-debug` depending +on both, `:app-release` depending on `:seam` only) and **run**, so the recorded result is the +binding that actually resolved — not one inferred from the annotations. + +| Framework | Versions | Evidence | +|---|---|---| +| Metro | 1.3.2, Kotlin 2.4.10 | release `noop` + empty decorator set; debug real binding; `@SingleIn` holder identical across both injection sites | +| kotlin-inject-anvil | 0.1.7, kotlin-inject 0.9.0, KSP 2.3.10, Kotlin 2.3.10 | `replaces` resolves both ways; an empty `Set` fails KSP outright | +| Square Anvil | 2.7.0, Dagger 2.60.1, Kotlin 2.2.20 | `replaces` resolves both ways — **only** after moving Dagger from KSP to kapt | +| Dagger | 2.60.1 | `@BindsOptionalOf` → `Optional.empty()` in release, present in debug; `@Multibinds` allows empty with no parameter | +| Hilt + Android variants | 2.60.1, AGP 9.3.0, Kotlin 2.4.10 | generated component: `Optional.of(...)` in debug vs `Optional.empty()` in release, with no release-side module | +| Koin | 4.2.2 | `getOrNull` null in release; `getAll` empty; `single` shares one instance | +| No DI + AGP variants | AGP 9.3.0, Kotlin 2.4.10 | debug APK dex carries the debug-only class, release APK carries zero occurrences | + +Classpath isolation was checked on the built artifacts in the Android project: the debug-only +project appeared on `debugRuntimeClasspath`, was absent from `releaseRuntimeClasspath`, and its +classes were absent from the release APK's dex. + +When a project's versions differ materially from these, re-check the sharp edge for that framework +before trusting the shape. + ## Reference - [Excluding from Release Builds](https://kitakkun.github.io/JetWhale/guide/excluding-from-release-builds) diff --git a/plugins/jetwhale/skills/integrate/references/anvil.md b/plugins/jetwhale/skills/integrate/references/anvil.md index be46509e..302e751e 100644 --- a/plugins/jetwhale/skills/integrate/references/anvil.md +++ b/plugins/jetwhale/skills/integrate/references/anvil.md @@ -2,66 +2,136 @@ Both frameworks merge contributions across the compile classpath and both spell displacement `replaces`, so the module layout and the seam are identical to Metro's — read -[`metro.md`](metro.md) first and treat this file as the delta. The differences are annotation -packages, the injection annotation, and how multibindings are expressed. +[`metro.md`](metro.md) first and treat this file as the delta. -**Verify against the version the project actually uses.** These libraries move, and -kotlin-inject-anvil in particular has changed the shape of its multibinding support across -releases. Check the annotation's parameters in the resolved artifact before relying on them. +Both were verified by building a four-module project (`:seam`, `:tooling`, `:app-debug` depending on +both, `:app-release` depending on `:seam` only) and running each app: release resolves the no-op, +debug resolves the real implementation, and a scoped holder is shared across two injection sites. +Each has one sharp edge that the Metro shape does not have. -## Square Anvil (Dagger-backed) +## kotlin-inject-anvil + +Verified with kotlin-inject-anvil 0.1.7, kotlin-inject 0.9.0, KSP 2.3.10, Kotlin 2.3.10. + +Annotations live under `software.amazon.lastmile.kotlin.inject.anvil`, with kotlin-inject's `@Inject` +(`me.tatarka.inject.annotations.Inject`) and `@SingleIn(AppScope::class)` for scoping. The component +is `@MergeComponent(AppScope::class)` on an abstract class, instantiated with +`AppComponent::class.create()`. ```kotlin // production module interface DebugToolingInitializer { - fun initialize() + fun initialize(): String } @ContributesBinding(AppScope::class) -class NoOpInitializer @Inject constructor() : DebugToolingInitializer { - override fun initialize() = Unit +@Inject +class NoOpInitializer : DebugToolingInitializer { + override fun initialize() = "noop" } ``` ```kotlin // debug-only module @ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) -@Singleton -class JetWhaleInitializer @Inject constructor( +@Inject +@SingleIn(AppScope::class) +class JetWhaleInitializer( private val agents: JetWhaleAgents, ) : DebugToolingInitializer { - override fun initialize() { /* startJetWhale { … } */ } + override fun initialize() = "jetwhale" } ``` -- `@ContributesMultibinding(AppScope::class)` is the equivalent of Metro's `@ContributesIntoSet` - for the HTTP-client decorator set. -- An empty `Set` needs a `@Multibinds` declaration in a `@ContributesTo` - module in production code — Dagger errors on an undeclared empty set the same way Metro does. -- Anvil is in maintenance mode; if the project is on a recent Kotlin and considering a move, Metro - is the successor and this wiring transfers with only the annotation packages changed. +### Use `replaces` for the HTTP decorator too — not a multibinding -## kotlin-inject-anvil +`@ContributesBinding` does carry a `multibinding: Boolean = false` parameter, so the Metro shape +looks like it should transfer. **It does not.** kotlin-inject has no equivalent of +`@Multibinds(allowEmpty = true)`, so a `Set` with zero contributions does not resolve, and the +release build fails at KSP time: + +``` +e: [ksp] Cannot find an @Inject constructor or provider for: Set +``` -Same annotation names under `software.amazon.lastmile.kotlin.inject.anvil`, with kotlin-inject's -`@Inject` (`me.tatarka.inject.annotations.Inject`) and `@SingleIn(AppScope::class)` for scoping. +Release contributing nothing is exactly the case this seam needs, so the multibinding route is +closed. Model the decorator as an ordinary binding with a no-op default and displace it the same way +as the initializer: ```kotlin -@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +// production +@ContributesBinding(AppScope::class) @Inject -@SingleIn(AppScope::class) -class JetWhaleInitializer( - private val agents: JetWhaleAgents, +class NoOpDecorator : HttpClientDecorator { + override fun decorate(client: HttpClient) = Unit +} + +// debug-only +@ContributesBinding(AppScope::class, replaces = [NoOpDecorator::class]) +@Inject +class JetWhaleHttpClientDecorator(private val agents: JetWhaleAgents) : HttpClientDecorator { + override fun decorate(client: HttpClient) { + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) + } +} +``` + +One mechanism for both seams, and it is the mechanism that works. + +## Square Anvil (Dagger-backed) + +Verified with Anvil 2.7.0, Dagger 2.60.1, Kotlin 2.2.20. + +```kotlin +// production module +@ContributesBinding(AppScope::class) +class NoOpInitializer @Inject constructor() : DebugToolingInitializer { + override fun initialize() = "noop" +} +``` + +```kotlin +// debug-only module +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +class JetWhaleInitializer @Inject constructor( + private val agents: FakeAgents, ) : DebugToolingInitializer { - override fun initialize() { /* startJetWhale { … } */ } + override fun initialize() = "jetwhale" } ``` -For the decorator set, kotlin-inject-anvil expresses multibinding contributions through -`@ContributesBinding`'s multibinding support rather than a separate annotation — confirm the exact -parameter in the project's version. If it is absent or awkward, skip the multibinding entirely: a -single `HttpClientDecorator` binding with a no-op default, displaced by `replaces` exactly like the -initializer, does the same job with one mechanism instead of two. +The component is `@MergeComponent(AppScope::class)`, instantiated with `DaggerAppComponent.create()`. + +### Dagger must run through kapt, not KSP + +Anvil is a Kotlin **compiler plugin**: it adds `@Component` and the merged supertypes during +compilation. KSP reads source before that happens, so Dagger's KSP processor never sees a component +to process. The build does not complain — it generates nothing at all, and compilation fails much +later on an unresolved `DaggerAppComponent`: + +``` +e: Unresolved reference: DaggerAppComponent +``` + +Switching Dagger's processor to kapt fixes it, and both variants then build and resolve correctly: + +```kotlin +plugins { + kotlin("kapt") +} +dependencies { + kapt("com.google.dagger:dagger-compiler:2.60.1") +} +``` + +If a project already runs Dagger through KSP, this is a real constraint to raise with the team +rather than something to work around silently. + +### Version reality + +Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20. A project on a newer Kotlin cannot simply +add it. Anvil is also effectively superseded — Metro is the successor, and this wiring transfers to +it with only the annotation packages changed and `@Multibinds(allowEmpty = true)` available again. ## Common failure modes @@ -69,4 +139,6 @@ initializer, does the same job with one mechanism instead of two. |---|---| | Duplicate binding for the seam type | `replaces` missing, or the contributions target different scopes | | Release cannot resolve the seam | The no-op is in the debug-only module | -| Contribution silently ignored | The debug module is not on the compile classpath of the component/graph declaration — the variant dependency has to sit on the module that merges | +| `Cannot find an @Inject constructor or provider for: Set<…>` | kotlin-inject-anvil: an empty multibinding. Use `replaces` with a no-op instead | +| `Unresolved reference: DaggerAppComponent`, nothing generated | Square Anvil with Dagger on KSP. Move Dagger to kapt | +| Contribution silently ignored | The debug module is not on the compile classpath of the component declaration — the variant dependency has to sit on the module that merges | diff --git a/plugins/jetwhale/skills/integrate/references/dagger-hilt.md b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md index 35bd8ae9..2471e9f4 100644 --- a/plugins/jetwhale/skills/integrate/references/dagger-hilt.md +++ b/plugins/jetwhale/skills/integrate/references/dagger-hilt.md @@ -1,83 +1,110 @@ # Wiring with Dagger or Hilt -Neither merges contributions off the classpath, so there is no `replaces` to lean on. The switch is -made by **supplying the binding from a different place per variant**: one module for debug, one for -release, providing the same type. +Neither has Metro's `replaces`. What they have instead is **optional bindings**: production code +declares that a binding *may be absent*, and the debug side is the only one that supplies it. That +is a better fit than it sounds — the release behaviour this seam wants is "do nothing", which is +exactly what absence means. -Android variant source sets make this cheap — `src/debug/` and `src/release/` inside a single -module, with `debugImplementation` for the JetWhale dependency. Nothing in `src/main/` ever names -JetWhale, so the release compilation never needs it. +Whether you need a release-side counterpart at all comes down to one difference: -## Layout +| | How the debug module reaches the graph | Release-side counterpart | +|---|---|---| +| **Hilt** | Discovered from `@InstallIn` on the classpath | **Not needed** — one-sided | +| **Plain Dagger** | Listed explicitly in `@Component(modules = [...])` | **Required** — a same-name module per variant | -``` -:app - build.gradle.kts debugImplementation("com.kitakkun.jetwhale:jetwhale-agent-runtime:") - src/main/…/DebugToolingInitializer.kt ← the seam, always compiled - src/debug/…/DebugToolingModule.kt ← binds JetWhaleInitializer - src/release/…/DebugToolingModule.kt ← binds NoOpInitializer -``` +Verified with Dagger 2.60.1, Hilt 2.60.1, AGP 9.3.0, Kotlin 2.4.10, KSP 2.3.10. -Both module files must declare the **same binding key**, and Hilt requires the same -`@InstallIn` component. Keeping the file name and class name identical across the two source sets -is what makes the pair obvious to the next reader. +## Hilt — one-sided, no release counterpart -## The seam +Declare the optional binding once, in `src/main`: ```kotlin -// src/main +// src/main — production code, never names JetWhale interface DebugToolingInitializer { - fun initialize() + fun initialize(): String } -``` - -## Release side -```kotlin -// src/release @Module @InstallIn(SingletonComponent::class) -object DebugToolingModule { - @Provides - @Singleton - fun provideInitializer(): DebugToolingInitializer = - object : DebugToolingInitializer { - override fun initialize() = Unit - } +abstract class SeamModule { + @BindsOptionalOf + abstract fun optionalInitializer(): DebugToolingInitializer } ``` -## Debug side +Supply it from `src/debug` only. There is no `src/release` file: ```kotlin // src/debug — the only source set that imports JetWhale @Module @InstallIn(SingletonComponent::class) -object DebugToolingModule { +object JetWhaleModule { @Provides @Singleton - fun provideNetworkAgent(): JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin() + fun agents(): JetWhaleAgents = JetWhaleAgents() @Provides - @Singleton - fun provideInitializer(agent: JetWhaleNetworkAgentPlugin): DebugToolingInitializer = + fun initializer(agents: JetWhaleAgents): DebugToolingInitializer = DebugToolingInitializer { startJetWhale { connection { host = "localhost"; port = 5080 } - plugins { register(agent) } + plugins { register(agents.network) } } } } ``` -`@Singleton` on the agent provider is what keeps one instance across the two call sites. +Inject `Optional` and call it: + +```kotlin +// src/main +@Inject lateinit var initializer: Optional + +initializer.ifPresent { it.initialize() } +``` + +`@Singleton` on the agent provider is what keeps one instance across the HTTP client and the session. + +This works because Hilt aggregates every `@InstallIn` module it finds on the variant's classpath. +The generated component proves it — same app, two variants: + +```java +// app/build/generated/hilt/component_sources/debug/…/DaggerApp_HiltComponents_SingletonC.java +App_MembersInjector.injectInitializer(instance, Optional.of(debugToolingInitializer())); + +// …/release/… +App_MembersInjector.injectInitializer(instance, Optional.empty()); +``` + +The release component contains no reference to `JetWhaleModule` at all, and no +`JetWhaleModule_*Factory` is generated for that variant. + +## Plain Dagger — the same-name module pair + +Without Hilt, `@Component(modules = [...])` names its modules explicitly, and `src/main` cannot name +a class that exists only in `src/debug`. So the module has to exist in both source sets under the +same fully-qualified name — empty in release, providing in debug. + +```kotlin +// src/main +@Singleton +@Component(modules = [SeamModule::class, DebugToolingModule::class]) +interface AppComponent { + fun initializer(): Optional +} +``` + +`SeamModule` (with `@BindsOptionalOf`) stays in `src/main`; `DebugToolingModule` is declared twice — +empty in `src/release`, providing in `src/debug`. Nothing keeps that pair in sync but convention, so +build both variants in CI. ## HTTP client capture -Use an optional multibinding so the release side contributes nothing: +Dagger's `@Multibinds` allows an empty set **by default** — unlike Metro, there is no `allowEmpty` +parameter to set: ```kotlin -// src/main — declares the (possibly empty) set +// src/main @Module @InstallIn(SingletonComponent::class) abstract class HttpClientDecoratorModule { @@ -90,29 +117,44 @@ abstract class HttpClientDecoratorModule { // src/debug @Provides @IntoSet -fun provideJetWhaleDecorator(agent: JetWhaleNetworkAgentPlugin): HttpClientDecorator = +fun jetwhaleDecorator(agents: JetWhaleAgents): HttpClientDecorator = HttpClientDecorator { client -> - client.plugin(HttpSend).intercept(agent.ktorSendInterceptor(client)) + client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client)) } ``` The production `HttpClient` / `OkHttpClient` provider injects `Set` and applies every element. In release the set is empty and the provider is unchanged. -## Plain Dagger, or a KMP project +## Gradle, and what it actually buys you -Without Hilt, the same split applies to the component's `modules = [...]` list: declare -`DebugToolingModule` in both source sets and list it once in the component. +```kotlin +dependencies { + implementation("com.google.dagger:hilt-android:2.60.1") + ksp("com.google.dagger:hilt-compiler:2.60.1") + debugImplementation(project(":tooling")) // or the JetWhale artifacts directly +} +``` + +Verified end to end on the built artifacts, not just the wiring: + +``` +:app:dependencies --configuration releaseRuntimeClasspath → no :tooling +:app:dependencies --configuration debugRuntimeClasspath → +--- project :tooling +debug APK dex strings: FakeAgents ×5 +release APK dex strings: FakeAgents ×0 +``` KMP has no variant source sets. Put the debug module in its own Gradle module and gate the -dependency on a Gradle property, or keep two thin entry-point modules — see the KMP section of -[`metro.md`](metro.md), which is framework-independent. +dependency on a Gradle property — see the KMP section of [`metro.md`](metro.md), which is +framework-independent. ## Failure modes | Symptom | Cause | |---|---| -| `DuplicateBindings` | Both source sets ended up on one compile — check that `src/debug` is not also listed in the release variant's source sets | -| `MissingBinding` in release only | The release module file is missing, or its `@InstallIn` component differs from the debug one | -| Unresolved reference to JetWhale in release | Something in `src/main` names a JetWhale type — the seam leaked | +| `MissingBinding` in release only | Plain Dagger with no release-side counterpart module, or `@BindsOptionalOf` never declared | +| `DuplicateBindings` | Both source sets ended up on one compile — check the release variant's source set list | +| Unresolved JetWhale reference in release | Something in `src/main` names a JetWhale type — the seam leaked | | Session connects, no traffic | Two agent instances — `@Singleton` missing on the agent provider | +| `The 'org.jetbrains.kotlin.android' plugin is no longer required since AGP 9.0` | AGP 9 has built-in Kotlin; drop the plugin rather than pinning AGP back | diff --git a/plugins/jetwhale/skills/integrate/references/koin.md b/plugins/jetwhale/skills/integrate/references/koin.md index dd749df1..758ea34a 100644 --- a/plugins/jetwhale/skills/integrate/references/koin.md +++ b/plugins/jetwhale/skills/integrate/references/koin.md @@ -70,7 +70,9 @@ val networkModule = module { ``` `getAll()` returns every definition of the type and is empty when none are declared, which is -exactly the release case. Declare the decorator only in `src/debug`: +exactly the release case — verified on Koin 4.2.2: the release side resolved `client[]` and +`getOrNull()` returned null, while the debug side resolved both. Declare +the decorator only in `src/debug`: ```kotlin // src/debug @@ -80,9 +82,8 @@ single { } ``` -Check `getAll`'s behaviour in the project's Koin version before relying on it; if it is unavailable, -an explicit `getOrNull()` with a nullable single works just as well and reads -more plainly. +On an older Koin without `getAll`, `getOrNull()` against a single definition +does the same job and reads more plainly. ## Gradle diff --git a/plugins/jetwhale/skills/integrate/references/metro.md b/plugins/jetwhale/skills/integrate/references/metro.md index 7f555f12..0442f15f 100644 --- a/plugins/jetwhale/skills/integrate/references/metro.md +++ b/plugins/jetwhale/skills/integrate/references/metro.md @@ -8,6 +8,11 @@ implementation from a module that only exists on the debug classpath. The variant-specific dependency therefore belongs on **the module that declares the graph**, usually the app module. +Verified with Metro 1.3.2 and Kotlin 2.4.10, by running a four-module project both ways: the release +app resolved `noop` with an empty decorator set, the debug app resolved the JetWhale binding, and +the `@SingleIn` holder reported the same instance identity through both the initializer and the +decorator. + ## Module layout ``` diff --git a/plugins/jetwhale/skills/integrate/references/no-di.md b/plugins/jetwhale/skills/integrate/references/no-di.md index 8833166f..5026230c 100644 --- a/plugins/jetwhale/skills/integrate/references/no-di.md +++ b/plugins/jetwhale/skills/integrate/references/no-di.md @@ -59,6 +59,11 @@ dependencies { } ``` +Verified on AGP 9.3.0 / Kotlin 2.4.10 by reading the built APKs rather than the wiring: the debug +APK's dex carried the debug-only class and the `"jetwhale:"` marker, while the release APK carried +`"noop"` and zero occurrences of the debug-only class. `:tooling` appeared on +`debugRuntimeClasspath` and was absent from `releaseRuntimeClasspath`. + ## HTTP client capture The client is built in production code, so give the seam a method that can do nothing: From 40c436e0ecae8a77a4fe63d0714cdd6b982f0e0e Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:28:48 +0900 Subject: [PATCH 4/5] docs(skills): reframe the Square Anvil section for readers already on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nobody adopts Anvil today — 2.7.0 is pinned to Kotlin 2.2.20 and Metro is its successor — so a how-to shaped section invited the wrong reading and buried the one thing that costs real time. Lead with the kapt constraint, since it has to be checked before any wiring is written and it fails silently: no warning, nothing generated, and a build error much later at an unresolved DaggerAppComponent. The wiring itself collapses to a few lines now that it is stated as a delta from Metro, and the migration exit gets its own closing section. The file header also names the two libraries as unrelated, which they are — they share a word and nothing else. --- plugins/jetwhale/skills/integrate/SKILL.md | 2 +- .../skills/integrate/references/anvil.md | 84 +++++++++++-------- 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/plugins/jetwhale/skills/integrate/SKILL.md b/plugins/jetwhale/skills/integrate/SKILL.md index 327f0a6f..fb9b5378 100644 --- a/plugins/jetwhale/skills/integrate/SKILL.md +++ b/plugins/jetwhale/skills/integrate/SKILL.md @@ -112,7 +112,7 @@ JetWhale-backed implementation that displaces it on the debug classpath only. |---|---|---| | Metro (`dev.zacsweers.metro`) | `references/metro.md` | Empty multibindings need `@Multibinds(allowEmpty = true)` | | kotlin-inject-anvil | `references/anvil.md` | **No empty multibindings at all** — use `replaces` for every seam | -| Square Anvil | `references/anvil.md` | **Dagger must run on kapt, not KSP**, or nothing is generated | +| Square Anvil (maintenance mode) | `references/anvil.md` | **Dagger must run on kapt, not KSP** — under KSP nothing is generated and nothing says so | | Hilt | `references/dagger-hilt.md` | `@InstallIn` is discovered, so the debug side is the only side | | Plain Dagger | `references/dagger-hilt.md` | Modules are listed, so a same-name module per variant is unavoidable | | Koin | `references/koin.md` | Runtime resolution — the compiler catches nothing | diff --git a/plugins/jetwhale/skills/integrate/references/anvil.md b/plugins/jetwhale/skills/integrate/references/anvil.md index 302e751e..b234263a 100644 --- a/plugins/jetwhale/skills/integrate/references/anvil.md +++ b/plugins/jetwhale/skills/integrate/references/anvil.md @@ -1,8 +1,13 @@ -# Wiring with Anvil / kotlin-inject-anvil +# Wiring with kotlin-inject-anvil, or an existing Square Anvil setup -Both frameworks merge contributions across the compile classpath and both spell displacement -`replaces`, so the module layout and the seam are identical to Metro's — read -[`metro.md`](metro.md) first and treat this file as the delta. +Two unrelated libraries that happen to share a name and the word `replaces`. Both merge +contributions across the compile classpath, so the module layout and the seam are identical to +Metro's — read [`metro.md`](metro.md) first and treat this file as the delta. + +- **kotlin-inject-anvil** (Amazon, actively developed) — the section below. +- **Square Anvil** — in maintenance mode, pinned to an old Kotlin. Nobody adopts it today, so that + section is written for a project that is *already* on it, and leads with the trap rather than the + syntax. Both were verified by building a four-module project (`:seam`, `:tooling`, `:app-debug` depending on both, `:app-release` depending on `:seam` only) and running each app: release resolves the no-op, @@ -78,42 +83,27 @@ class JetWhaleHttpClientDecorator(private val agents: JetWhaleAgents) : HttpClie One mechanism for both seams, and it is the mechanism that works. -## Square Anvil (Dagger-backed) +## Square Anvil — for a project already on it -Verified with Anvil 2.7.0, Dagger 2.60.1, Kotlin 2.2.20. +Do not reach for Anvil to solve this. Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20, so +a project on anything newer cannot add it, and Metro is its successor. This section exists for the +codebase that is already there — usually a large, long-lived Android app, which is exactly the kind +that most needs the debugger kept out of its release build. -```kotlin -// production module -@ContributesBinding(AppScope::class) -class NoOpInitializer @Inject constructor() : DebugToolingInitializer { - override fun initialize() = "noop" -} -``` +### The trap: Dagger must run through kapt, not KSP -```kotlin -// debug-only module -@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) -class JetWhaleInitializer @Inject constructor( - private val agents: FakeAgents, -) : DebugToolingInitializer { - override fun initialize() = "jetwhale" -} -``` +Check this **before** writing any wiring. Anvil is a Kotlin **compiler plugin**: it adds +`@Component` and the merged supertypes during compilation. KSP reads source before that happens, so +Dagger's KSP processor never sees a component to process. -The component is `@MergeComponent(AppScope::class)`, instantiated with `DaggerAppComponent.create()`. - -### Dagger must run through kapt, not KSP - -Anvil is a Kotlin **compiler plugin**: it adds `@Component` and the merged supertypes during -compilation. KSP reads source before that happens, so Dagger's KSP processor never sees a component -to process. The build does not complain — it generates nothing at all, and compilation fails much -later on an unresolved `DaggerAppComponent`: +Nothing announces this. No warning, no error, nothing generated at all — and the build fails much +later, somewhere that looks unrelated: ``` e: Unresolved reference: DaggerAppComponent ``` -Switching Dagger's processor to kapt fixes it, and both variants then build and resolve correctly: +Both variants build and resolve correctly once Dagger's processor moves to kapt: ```kotlin plugins { @@ -124,14 +114,34 @@ dependencies { } ``` -If a project already runs Dagger through KSP, this is a real constraint to raise with the team -rather than something to work around silently. +A project that already runs Dagger through KSP cannot have both. That is a constraint to raise with +the team, not something to switch underneath them. + +### The wiring itself + +Identical to Metro's, with `com.squareup.anvil.annotations.ContributesBinding`, Dagger's +`@Inject constructor()` and `@Singleton`, and `@MergeComponent(AppScope::class)` on the component — +instantiated as `DaggerAppComponent.create()`. `replaces` still names the contributing class: + +```kotlin +// production module +@ContributesBinding(AppScope::class) +class NoOpInitializer @Inject constructor() : DebugToolingInitializer { … } + +// debug-only module +@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class]) +class JetWhaleInitializer @Inject constructor(private val agents: JetWhaleAgents) : + DebugToolingInitializer { … } +``` + +Verified with Anvil 2.7.0, Dagger 2.60.1, Kotlin 2.2.20. -### Version reality +### The exit -Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20. A project on a newer Kotlin cannot simply -add it. Anvil is also effectively superseded — Metro is the successor, and this wiring transfers to -it with only the annotation packages changed and `@Multibinds(allowEmpty = true)` available again. +This wiring transfers to Metro with only the annotation packages changed — and there +`@Multibinds(allowEmpty = true)` is available again, so the HTTP decorator can go back to being a +multibinding. If the team is weighing a migration, the seam is a small, self-contained place to +start. ## Common failure modes From b12862382455b41c6e407da22aec0b71b8072ead Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:06:29 +0900 Subject: [PATCH 5/5] docs(skills): soften the Anvil reference from directives to description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference files are prose a person reads, not instructions an agent executes, and the Anvil section had drifted into commanding the reader: "Do not reach for Anvil to solve this" tells someone already on Anvil not to make a choice they are not making, and reads as a judgement of their stack. The imperatives in SKILL.md stay — those genuinely direct an agent's next action. Also trims the dramatised description of the KSP failure to the fact itself: there is no warning, nothing is generated, and the build fails later somewhere unrelated. --- .../skills/integrate/references/anvil.md | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/plugins/jetwhale/skills/integrate/references/anvil.md b/plugins/jetwhale/skills/integrate/references/anvil.md index b234263a..0535195c 100644 --- a/plugins/jetwhale/skills/integrate/references/anvil.md +++ b/plugins/jetwhale/skills/integrate/references/anvil.md @@ -5,9 +5,8 @@ contributions across the compile classpath, so the module layout and the seam ar Metro's — read [`metro.md`](metro.md) first and treat this file as the delta. - **kotlin-inject-anvil** (Amazon, actively developed) — the section below. -- **Square Anvil** — in maintenance mode, pinned to an old Kotlin. Nobody adopts it today, so that - section is written for a project that is *already* on it, and leads with the trap rather than the - syntax. +- **Square Anvil** — in maintenance mode and pinned to an old Kotlin, so that section is written for + a project that is already on it, and leads with the constraint to check rather than the syntax. Both were verified by building a four-module project (`:seam`, `:tooling`, `:app-debug` depending on both, `:app-release` depending on `:seam` only) and running each app: release resolves the no-op, @@ -85,19 +84,19 @@ One mechanism for both seams, and it is the mechanism that works. ## Square Anvil — for a project already on it -Do not reach for Anvil to solve this. Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20, so -a project on anything newer cannot add it, and Metro is its successor. This section exists for the -codebase that is already there — usually a large, long-lived Android app, which is exactly the kind -that most needs the debugger kept out of its release build. +Anvil 2.7.0 (October 2025) is built against Kotlin 2.2.20, and Metro is its successor, so this is +not a library a project adopts today. It is one a project already has — often a large, long-lived +Android app, which is exactly the kind that most benefits from keeping the debugger out of its +release build. -### The trap: Dagger must run through kapt, not KSP +### Check the Dagger processor first: kapt, not KSP -Check this **before** writing any wiring. Anvil is a Kotlin **compiler plugin**: it adds +Worth settling before writing any wiring. Anvil is a Kotlin **compiler plugin**: it adds `@Component` and the merged supertypes during compilation. KSP reads source before that happens, so Dagger's KSP processor never sees a component to process. -Nothing announces this. No warning, no error, nothing generated at all — and the build fails much -later, somewhere that looks unrelated: +There is no warning for this — nothing is generated, and the build fails later at a point that looks +unrelated: ``` e: Unresolved reference: DaggerAppComponent @@ -114,8 +113,8 @@ dependencies { } ``` -A project that already runs Dagger through KSP cannot have both. That is a constraint to raise with -the team, not something to switch underneath them. +A project that already runs Dagger through KSP cannot have both, so this is a decision for the team +rather than a detail to change in passing. ### The wiring itself