Step 5: App-Scope Collapse — DB-cascade (5a) + SnapshotExportRunner (5b) Metro-owned#176
Conversation
…3 catalog, KMP convention plugin, NO_ACTUAL_FOR_EXPECT guard Phase C.0: a green toolchain foundation with ZERO migration. No module converted, no Room rename, no DI slice — every KMP-facing addition is declared but applied to nothing. - catalog: KSP 2.3.6 -> 2.3.9 (2.3.6 silently skips KMP/native codegen — a proven false-green from Probe-2); add UNAPPLIED metro 1.1.1, room3 3.0.0, sqlite-bundled 2.7.0 + convention-kmpLibrary alias. - promote KmpLibraryConventionPlugin (registered in build-logic, applied to zero modules; target/source-set config deferred to the first C.1 slice). - add Detekt rule NoActualForExpectSuppressionRule: repo-wide ban on @Suppress("NO_ACTUAL_FOR_EXPECT"), which masks a missing `actual` and produces the exact false-green Probe-2 caught. Validated safe as a blanket ban (repo has zero expect/actual and zero such suppressions today). Gate proven, not just green: full-repo assembleDebug + detekt green under KSP 2.3.9; 32/32 kspDebugKotlin AND 32/32 kspDebugUnitTestKotlin executed (0 cached) -> existing Hilt-Android codegen survives 2.3.9 on every module; 1094 unit tests pass. Guard proven end-to-end: a planted suppression fails detekt citing the rule, green on removal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdATiNmTB5zFWUWH2ZqJeK
… catalog, KMP convention plugin, NO_ACTUAL_FOR_EXPECT guard) (#175) ## Phase C.0 — green toolchain foundation, ZERO migration No-regret foundation for the Android→KMP migration. **No module is converted, no Room rename is executed, no DI slice is started.** Every KMP-facing addition is *declared but applied to nothing* — this PR only makes the toolchain ready and adds one guardrail. ### What changed (7 files, +284/-1) - **Version catalog** — KSP `2.3.6` → `2.3.9`; add **unapplied** `metro 1.1.1`, `room3 3.0.0`, `sqlite-bundled 2.7.0`, and the `convention-kmpLibrary` alias + KMP/Metro plugin aliases. - **`KmpLibraryConventionPlugin`** — promoted into `build-logic` and registered, but **applied to zero modules**. Target/source-set/Metro/Room3 wiring is deliberately deferred to the first C.1 slice rather than guessed here. - **`NoActualForExpectSuppressionRule`** (Detekt) — repo-wide ban on `@Suppress("NO_ACTUAL_FOR_EXPECT")`, which masks a missing `actual` and produces a false-green build (the exact issue Probe-2 caught when KSP 2.3.6 silently skipped native codegen). Validated safe as a blanket ban: the repo has **zero** `expect`/`actual` and **zero** such suppressions today. ### Why KSP 2.3.9 / Metro 1.1.1 - KSP **2.3.6 silently skips KMP/native codegen** — a proven false-green; 2.3.9 is the fix. - Locked at **Kotlin 2.3.20** (CMP caps the KMP world at 2.3.x). Metro **1.1.1** is the last release compatible with 2.3.20 (1.2.0+ needs Kotlin 2.4.0). ### Gate — proven, not just green - `assembleDebug` + `detekt` green under KSP 2.3.9. - **32/32 `kspDebugKotlin` AND 32/32 `kspDebugUnitTestKotlin` executed with 0 cached** (the version bump invalidated every KSP cache) → existing **Hilt-Android codegen survives KSP 2.3.9 on every module**, including the three apps' `@HiltAndroidApp` aggregation. This was the migration's #1 unverified assumption. - **1094 unit tests pass.** `detekt` clean across all 32 modules with `config.validation: true` (rule is correctly registered). - **Guard proven end-to-end:** a planted `@Suppress("NO_ACTUAL_FOR_EXPECT")` fails `detekt` citing `NoActualForExpectSuppressionRule`; green on removal. 8/8 unit tests cover 4 positive + 4 negative controls. ### Not in this PR (C.1, on macOS for iOS/K-N compile) - Room 2→3 migration — scoped as **CHUNKY** (driver now mandatory, `@TypeConverter`→`@ColumnTypeConverter`, `Migration.migrate(SQLiteConnection)`, driver-aware `MigrationTestHelper`, explicit PagingSource converter). C.1 must also add `room3-paging`, `room3-testing`, and the **room3 Gradle plugin coordinate (unconfirmed on Google Maven — blocker)**. - First Hilt→Metro DI slice — recommended target **`feature/archive`** (off the Room track; exercises the representative cross-module `@Singleton` bridge; the shared `core:ui:mvi` `hiltViewModel()` resolution rework is the real first-slice prerequisite). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LdATiNmTB5zFWUWH2ZqJeK
Add rememberMetroStoreProcessor to core:ui:mvi as the transition-era twin
of the hiltViewModel-backed rememberStoreProcessor overloads. A migrated
feature supplies a factory lambda that resolves its Metro graph and builds
the BaseStore subclass; because BaseStore already IS an androidx ViewModel,
the Metro-created Store is retained directly via viewModel { initializer { } }
(NavBackStackEntry-scoped — the exact lifetime hiltViewModel gives today),
then handed to the existing backend-agnostic rememberStoreProcessor(StoreCreator)
overload so both paths share identical lifecycle/analytics/render-trace wiring.
Added alongside the Hilt path, not replacing it: the 12 non-migrated features
keep resolving their Store via hiltViewModel, untouched. core:ui:mvi stays an
Android module here; the KMP split (commonMain/androidMain/iosMain) lands in
C.1.2 where feature/archive must compile for iosSimulatorArm64.
Gate: full assembleDebug green; all 12 features' unit tests pass (593 tests,
0 failures); core:ui:mvi detekt (custom MVI rules) green, zero suppressions.
Blast radius CORE_ONLY — no feature-module changes forced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make :core:core a pure-Kotlin Kotlin Multiplatform module (commonMain + iosSimulatorArm64), the foundation the archive iOS cascade needs. All Hilt @modules and Android-framework impls move to a new :core:core-android module. core:core (31 commonMain / 6 androidMain / 6 iosMain): - Logger/Log (Kermit, already KMP), FirebaseEvent/EventsFilter, model/, result/, utils/, DurationFormat, AppCoroutineScope(Impl), the ResourceWrapper + AppReinitializer + PlatformInfoProvider + ImageStorage interfaces, ImageRef — all pure-Kotlin commonMain. - expect/actual seams: FirebaseAnalyticsHolder + FirebaseCrashlyticsHolder (androidMain = real com.google.firebase.*, iosMain = no-op) and the 4 dispatcher qualifiers (androidMain actual carries @javax.inject.Qualifier so Hilt still recognizes them; iosMain actual is a plain annotation). 44 dispatcher consumers and 54 ResourceWrapper consumers keep their imports unchanged. - CoroutineExt (asyncMap/asyncForEach/asyncScope/asyncAssociate) is commonMain: ConcurrentHashMap in asyncAssociate replaced with awaited-pairs .toMap() (KMP-safe, deterministic last-write-wins). core:core-android (Android library, runs Hilt): the 3 Hilt @modules (Core/Platform/ImageStorage) + Android impls (AndroidResourceWrapper, AndroidTempFileProvider, AndroidPlatformInfoProvider, ImageStorageImpl, AndroidAppReinitializer, RelativeTimeFormat) + the java.io.File-typed TempFileProvider. Added to :app:app so its @modules aggregate into the app's single Dagger graph. The 4 modules that reference its concrete Android symbols (feature/home, feature/recovery, feature/settings, core/ui/test-utils) depend on it directly. A KMP module cannot run the Hilt plugin, so this split is required. Gate: assembleDebug BUILD SUCCESSFUL (345 tasks executed, app Hilt graph intact); core:core compileKotlinIosSimulatorArm64 executed, zero NO_ACTUAL_FOR_EXPECT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th intact) First real per-feature Hilt→Metro flip, proving the repeatable pattern on one feature. DI-axis only: archive stays an android-library composeLibrary — NO KMP, NO iOS target, NO expect/actual. The other 11 features + app remain on Hilt via the dual-path Store seam (rememberStoreProcessor / MetroStoreProcessor, acc5711). Archive DI is now 100% Metro: - ArchiveGraph: one @DependencyGraph(scope=ArchiveScope) with a @DependencyGraph.Factory taking the 8 app-scoped Hilt @singletons as @provides bound instances; the two @BINDS (Interactor, HandlerStore) migrated here from the deleted ArchiveModule. - ArchiveHiltEntryPoint: @entrypoint @Installin(SingletonComponent) exposing those 8 deps. Consumed via EntryPointAccessors.fromApplication in ArchiveFeature; aggregates into the app Dagger graph automatically (same pattern as feature/recovery) — ZERO app changes. - Handlers/Interactor/HandlerStore: @singlein(ArchiveScope) (== @ViewModelScoped); Store is unscoped, retained by the Android ViewModelStore via rememberMetroStoreProcessor. - @DefaultDispatcher resolved on the Hilt side of the bridge; Metro receives one unqualified CoroutineDispatcher (Metro ignores javax.inject qualifiers). Toolchain made Metro-aware (additive, dual-path-preserving — Hilt path unchanged): - HiltScopeRule accepts @singlein as the @ViewModelScoped analogue (+ unit tests). - detekt LongParameterList ignoreAnnotated += @DependencyGraph.Factory (the bridge's bound-instance surface is intrinsically one param per dep — no code @Suppress). Gates (all EXECUTED, not cached): - :feature:archive:compileDebugKotlin green; Metro codegen real (ArchiveGraph$Impl, $Factory$Impl, $BindsMirror, *$MetroFactory generated). - clean :app:dev:assembleDebug green — Metro + Hilt coexist through @HiltAndroidApp merge. - Full testDebugUnitTest green (11 Hilt features + app intact); archive tests 13/0/0. - detekt green incl. custom MVI rules, zero suppressions. - Bridge === proven at RUN on archive's real graph (ArchiveGraphBridgeTest, JUnit5, no Hilt-JUnit4 harness): AnalyticsHolder/LoggerHolder reach the Store as the same instances. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…cludeJavax) The M0 bridge STRIPPED qualifiers — app-scoped deps flowed into the Metro graph by type only. Fine for archive (one dispatcher), but a collision audit found 4 of the remaining features (exercise, settings, single-training, recovery) bridge TWO same-typed CoroutineDispatchers distinguished only by @DefaultDispatcher / @iodispatcher / @MainImmediateDispatcher → under strip they'd arrive bare = duplicate binding / silent wrong-dispatcher. This fix lands before any feature flip so the batch inherits it. Fix: `metro { interop { includeJavax() } }` in the feature build. Metro then reads javax.inject qualifiers directly, so the existing core:core @DefaultDispatcher / @iodispatcher (expect/actual, @javax.inject.Qualifier) become real Metro binding keys — Metro's key is (type + qualifier), so two same-typed dispatchers coexist and each resolves by its qualifier. No core:core change, no new qualifier annotations, no lint change: the same @DefaultDispatcher a feature already imports works on both the Hilt EntryPoint side and the Metro graph side. (Chosen over dual-annotating core:core: strictly smaller blast radius, same batch-scalability — every feature's metro block gets the one interop line.) Applied to archive: @DefaultDispatcher restored (qualified) on the graph factory param and the interactor consumer — no longer stripped. Proof (QualifiedBridgeCollisionTest, focused two-dispatcher graph mirroring a colliding feature): two @provides CoroutineDispatchers qualified @DefaultDispatcher + @iodispatcher, consumed by two @Inject consumers — each resolves to its correct instance (=== identity, no cross-wire) at both accessor and consumer level. The test compiling AT ALL proves includeJavax registered the javax qualifiers (else duplicate-binding compile error). The strip-bug cannot recur. Gates (all EXECUTED, not cached): - :feature:archive:compileDebugKotlin green; Metro codegen real (ArchiveGraph$Impl/$Factory$Impl). - clean :app:dev:assembleDebug green (383 tasks executed) — qualified bridge + includeJavax coexist through @HiltAndroidApp merge; dev-debug.apk produced. - full testDebugUnitTest green (11 Hilt features + app intact); archive 14 tests incl. 2 bridge proofs. - detekt green incl. custom MVI rules, zero suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…st AppScope on Handlers) The M0 Metro-awareness (batch#... M0 5ec40cb) matched @singlein by NAME only (annotationNames.contains("SingleIn")) — it never read the scope argument, so @singlein(AppScope) on a *Handler would green-light. A Handler is per-screen, feature-scoped, NEVER app-scoped; pinning one to the app singleton graph is a real mis-scope the rule should catch. Fix: on the Metro path, read the @singlein(<Scope>::class) argument and reject a `*Handler` scoped to AppScope. Minimal + sound — a Handler-only AppScope blacklist, NOT full per-feature scope-matching (deliberately out of scope: higher false-positive risk mid-batch). referencesScope() strips "::class" and any package qualifier and matches the scope simple name, so both `AppScope::class` and `pkg.AppScope::class` are caught while a feature scope (`ArchiveScope::class`) passes. VERIFICATION-FIRST: archive's 3 Handlers carry @singlein(ArchiveScope::class) (the feature scope, not AppScope) → :feature:archive:detekt stays green. The fix is not over-strict. Tests (7, all green): Handler @singlein(<FeatureScope>) passes; Handler @singlein(AppScope) fails; non-Handler @singlein(AppScope) NOT flagged (guard is Handler-scoped); the Hilt-path guard (no @singlein → require @ViewModelScoped) still fires. Bisect-green: :lint-rules:compileKotlin + full detekt both pass independently. No feature touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…ualified dispatchers + Context bridge) Lands the stopped commit #3 on the hardest collider (18 app-scoped bound instances — most in the app), proving BOTH bridge mechanisms in situ on a real feature. Flip (DI-axis only, exactly as archive M0): - SettingsGraph: @DependencyGraph(scope=SettingsScope) + @DependencyGraph.Factory taking the 18 app-scoped @singletons as @provides bound instances; the 3 @BINDS (SettingsInteractor, BackupInteractor, SettingsHandlerStore) migrated from the deleted SettingsModule. - SettingsHiltEntryPoint (@Installin SingletonComponent) exposes them; consumed via EntryPointAccessors in the rememberMetroStoreProcessor lambda. App-module untouched. - Handlers @singlein(SettingsScope) (pass the commit#2 scope-arg rule); Store unscoped. Two same-typed dispatchers (@DefaultDispatcher + @iodispatcher, both CoroutineDispatcher): stay QUALIFIED into Metro via includeJavax (metro block) → (type + qualifier) keys → resolve distinctly, never merge. @ApplicationContext Context — the 3-point mechanic (Metro never sees the Hilt qualifier): 1. EntryPoint accessor KEEPS @ApplicationContext (Hilt resolves the app Context). 2. Graph factory param is PLAIN Context (no qualifier). 3. BackupClickHandler consumer DROPS @ApplicationContext → plain Context. Licensed by M2 Part A: every feature has ≤1 distinct app-scoped @ApplicationContext Context, none activity-scoped — so binding one bare Context per graph is unambiguous (NOT the blind strip). INTEGRATION PROOF (SettingsGraphBridgeTest, settings' REAL graph, 4/4 pass): - the two qualified dispatchers resolve to their distinct === instances, no cross-wire; - the bare Context reaches the graph as === the app Context; - settingsStore constructs — transitively wiring the Context-consuming BackupClickHandler and both dispatcher-consuming interactors (whole 18-dep graph resolves live); - bridged singletons reach the Store by identity. Toolchain (additive, dual-path-preserving): detekt TooManyFunctions ignoreAnnotated += dagger.hilt.EntryPoint (a bridge EntryPoint has one accessor per bridged dep — intrinsic, not a smell; no Hilt code carries @entrypoint). No code @Suppress. Gates (all EXECUTED, not cached): - :feature:settings:compileDebugKotlin green; Metro codegen real (SettingsGraph$Impl/$Factory$Impl/$BindsMirror). - clean :app:dev:assembleDebug green — settings Metro + Hilt coexist through @HiltAndroidApp; dev-debug.apk produced. - full testDebugUnitTest green (the other 11 Hilt features + app intact); settings tests incl. the 4 integration proofs. - detekt green incl. custom MVI rules, zero suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…+ assisted Store) Wave 1, flip 1 of 2. First ASSISTED-Store flip in the batch (archive/settings were plain Feature). 14 app-scoped bound instances. Assisted Store: ExerciseStoreImpl takes the Screen.Exercise route arg via @assisted, so the graph exposes the assisted ExerciseStoreImpl.Factory as its root — NEVER the Store (exposing an assisted type = [Metro/InvalidBinding]). dagger.assisted.* → dev.zacsweers.metro.* (@AssistedInject/@Assisted/@AssistedFactory); the @AssistedFactory keeps `Factory : StoreFactory<Screen.Exercise, ExerciseStoreImpl>` (SAM create(screen), Metro-compatible). ExerciseFeature calls storeFactory.create(screen) inside the rememberMetroStoreProcessor lambda. Dispatcher collision — @DefaultDispatcher + @MainImmediateDispatcher (both CoroutineDispatcher): stay QUALIFIED via includeJavax. THIRD dispatcher qualifier proven — @MainImmediateDispatcher is a javax @qualifier, rides the existing bridge, resolves to a distinct key. @ApplicationContext Context (ClickHandler) — the 3-point mechanic (Metro never sees the Hilt qualifier): EntryPoint accessor KEEPS @ApplicationContext → factory param PLAIN Context → ClickHandler consumer DROPS @ApplicationContext. One bare Context per graph. Standard flip: ExerciseGraph @DependencyGraph(scope=ExerciseScope) + Factory (14 bound instances) + 2 @BINDS (ExerciseInteractor, ExerciseHandlerStore); ExerciseHiltEntryPoint via EntryPointAccessors; 4 handlers @singlein(ExerciseScope); interactor + 4 usecases + HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete ExerciseModule. App-module untouched. INTEGRATION PROOF (ExerciseGraphBridgeTest, exercise's REAL graph, 3/3): - @default and @MainImmediate resolve to distinct === instances, no cross-wire (third-qualifier); - bare Context reaches the graph as === the app Context; - the assisted ExerciseStoreImpl.Factory is resolvable (whole 14-dep graph wired). NO new config exemption needed — the 3 prior exemptions (LongParameterList/@DependencyGraph.Factory, HiltScopeRule scope-arg, TooManyFunctions/@entrypoint) cover exercise. Toolchain patterns held. Gates (all EXECUTED, not cached): - :feature:exercise:compileDebugKotlin green; Metro codegen real (ExerciseGraph$Impl/$Factory$Impl/ $BindsMirror + ExerciseStoreImpl$Factory$Impl — the assisted factory impl). - clean :app:dev:assembleDebug green — exercise Metro + Hilt coexist through @HiltAndroidApp. - full testDebugUnitTest green (other Hilt features + app intact). - detekt green incl. custom MVI rules, zero suppressions. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
… Context, assisted Store) Wave 1, flip 2 of 2. Pure dispatcher-collision flip — no Context (M2 Part A verified 0 sites). Assisted Store (Screen.Training route arg). 13 app-scoped bound instances. Dispatcher collision — @DefaultDispatcher + @MainImmediateDispatcher (both CoroutineDispatcher): stay QUALIFIED via includeJavax → two distinct (type + qualifier) binding keys, resolve distinctly, never merge. Same third-qualifier path proven live in wave1a (exercise). Assisted Store: dagger.assisted.* → dev.zacsweers.metro.* (@AssistedInject/@Assisted/ @AssistedFactory); graph exposes the assisted SingleTrainingStoreImpl.Factory (never the Store); SingleTrainingFeature calls storeFactory.create(screen) in the rememberMetroStoreProcessor lambda. Standard flip: SingleTrainingGraph @DependencyGraph(scope=SingleTrainingScope) + Factory (13 bound instances) + 2 @BINDS; SingleTrainingHiltEntryPoint via EntryPointAccessors; 4 handlers @singlein(SingleTrainingScope); interactor + HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete SingleTrainingModule. App-module untouched; no Context mechanic (none needed). INTEGRATION PROOF (SingleTrainingGraphBridgeTest, real graph, 2/2): - @default and @MainImmediate resolve to distinct === instances, no cross-wire; - the assisted SingleTrainingStoreImpl.Factory is resolvable (whole 13-dep graph wired). NO new config exemption needed — the 3 prior exemptions cover it. Toolchain patterns held. Gates (all EXECUTED, not cached): - :feature:single-training:compileDebugKotlin green; Metro codegen real (SingleTrainingGraph$Impl/ $Factory$Impl/$BindsMirror + SingleTrainingStoreImpl$Factory$Impl). - clean :app:dev:assembleDebug green — 4 features now on Metro coexist with Hilt through @HiltAndroidApp. - full testDebugUnitTest green (other Hilt features + app intact). - detekt green incl. custom MVI rules, zero suppressions. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…sisted, simplest) Wave 2 checkpoint, flip 1 of 3. Non-collider, ASSISTED Store (Screen.ExerciseImage), the SIMPLEST graph in the batch — 4 bound instances (Navigator, StoreDispatchers, AnalyticsHolder, LoggerHolder), no dispatcher, no Context, 1 @BINDS. Proves the assisted-bulk flip is mechanical on a minimal live feature. Mechanic = exercise template (04fba62): ImageViewerGraph @DependencyGraph(scope= ImageViewerScope) exposing the assisted ImageViewerStoreImpl.Factory (never the Store); ImageViewerHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete ImageViewerModule. App untouched. INTEGRATION PROOF (ImageViewerGraphBridgeTest, real graph): the assisted Factory is resolvable from the 4 bridged singletons. NO new config exemption. Gates all EXECUTED: compile (codegen real incl ImageViewerStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (5 features Metro coexist), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…LAIN, bulk) Wave 2 checkpoint, flip 2 of 3. Non-collider, PLAIN Store (@hiltviewmodel, no assisted — a BottomBar destination with no route args) — the archive template on a live BULK feature. Proves the plain-bulk flip is mechanical, not just the hand-built M0 archive. 8 bound instances (ExerciseRepository, TagRepository, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger, @DefaultDispatcher), 2 @BINDS. Mechanic = archive template (5ec40cb): AllExercisesGraph @DependencyGraph(scope= AllExercisesScope) exposing the Store directly as val allExercisesStore (PLAIN — not a factory); AllExercisesHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store class-@Inject NO @singlein; delete AllExercisesModule. Single @DefaultDispatcher qualified via includeJavax (no collision). App untouched. INTEGRATION PROOF (AllExercisesGraphBridgeTest, real graph, 2/2): plain graph constructs the Store from the 8 bridged singletons; bridged singletons reach the Store by === identity. NO new config exemption (only import-ordering auto-fixed). Gates all EXECUTED: compile (codegen real AllExercisesGraph$Impl/$Factory$Impl/$BindsMirror), clean :app:dev:assembleDebug (6 features Metro coexist), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…sisted, @io) Wave 2 checkpoint, flip 3 of 3. Non-collider, ASSISTED Store (Screen.PastSession), single @iodispatcher (no collision). 9 bound instances (SessionRepository, SetRepository, PersonalRecordRepository, ResourceWrapper, Navigator, @iodispatcher, StoreDispatchers, Analytics/Logger), 2 @BINDS. Proves the assisted-bulk flip with a lone non-Default qualified dispatcher (@io alone rides includeJavax and resolves under its qualifier — no collision needed). Mechanic = exercise template (04fba62): PastSessionGraph @DependencyGraph(scope=PastSessionScope) exposing the assisted PastSessionStoreImpl.Factory (never the Store); PastSessionHiltEntryPoint via EntryPointAccessors; 4 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete PastSessionModule. App untouched. INTEGRATION PROOF (PastSessionGraphBridgeTest, real graph): the assisted Factory is resolvable — proving the @IO-qualified graph wired all 9 bridged singletons. NO new config exemption (only import-ordering + one unused-import auto-fixed — confirming NoUnusedImports backstops the dupe-import gate). Gates all EXECUTED: compile (codegen real incl PastSessionStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (7 features Metro coexist), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 1 of 5. Non-collider, PLAIN Store (archive template, BottomBar destination), single @DefaultDispatcher, 0 Context — matches the shape-map. 8 bound instances (TrainingRepository, TagRepository, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger, @DefaultDispatcher), 2 @BINDS. Structurally identical to all-exercises (wave2b). AllTrainingsGraph @DependencyGraph(scope=AllTrainingsScope) exposing the Store directly; AllTrainingsHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store class-@Inject no @singlein; delete AllTrainingsModule. INTEGRATION PROOF (real graph, 2/2): plain graph constructs the Store; bridged singletons reach it by === identity. Map-match confirmed: 0 Context, @default only. Gates all EXECUTED: compile (codegen real), clean :app:dev:assembleDebug (8 features Metro), full testDebugUnitTest, detekt green. Bisect-green. NO new config exemption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 2 of 5. Non-collider, PLAIN Store (archive template, BottomBar), single @DefaultDispatcher, 0 Context — matches the shape-map. 9 bound instances (TrainingRepository, SessionRepository, SessionConflictResolver, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger, @DefaultDispatcher), 2 @BINDS. HomeGraph @DependencyGraph(scope=HomeScope) exposing the Store directly; HomeHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store class-@Inject no @singlein; delete HomeModule. App untouched. INTEGRATION PROOF (real graph, 2/2): plain graph constructs the Store; bridged singletons reach it by === identity. Map-match confirmed: 0 Context, @default only. Gates all EXECUTED: compile (codegen real), clean :app:dev:assembleDebug (9 features Metro), full testDebugUnitTest, detekt green. Bisect-green. NO new config exemption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…fault) Wave 3, flip 3 of 5. Non-collider, ASSISTED Store (Screen.ExerciseChart), single @DefaultDispatcher, 0 Context — matches the shape-map. 8 bound instances (ExerciseRepository, SessionRepository, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger, @DefaultDispatcher), 2 @BINDS. ExerciseChartGraph @DependencyGraph(scope=ExerciseChartScope) exposing the assisted ExerciseChartStoreImpl.Factory (never the Store); ExerciseChartHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete ExerciseChartModule. App untouched. INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 8 bridged singletons wired. Map-match confirmed: 0 Context, @default only. Gates all EXECUTED: compile (codegen real incl ExerciseChartStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (10 features Metro), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 4 of 5. Non-collider, ASSISTED Store (Screen.PlanEditor), single @DefaultDispatcher, 0 Context — matches the shape-map. 5 handlers. 8 bound instances (ExerciseRepository, TrainingExerciseRepository, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger, @DefaultDispatcher), 2 @BINDS. PlanEditorGraph @DependencyGraph(scope=PlanEditorScope) exposing the assisted PlanEditorStoreImpl.Factory (never the Store); PlanEditorHiltEntryPoint via EntryPointAccessors; 5 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store assisted, unscoped; delete PlanEditorModule. App untouched. INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 8 bridged singletons wired. Map-match confirmed: 0 Context, @default only. Gates all EXECUTED: compile (codegen real incl PlanEditorStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (11 features Metro), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
, largest feature) Wave 3, flip 5 of 5. The LARGEST feature — non-collider, ASSISTED Store (Screen.LiveWorkout), single @DefaultDispatcher, 0 Context — matches the shape-map. 6 handlers + 2 feature-scoped mappers (LiveSetMutator, StateStatusMapper) + ExercisePickerHandler, all @singlein(LiveWorkoutScope) (feature-internal, Metro-constructed — NOT bridged). 13 app-scoped bound instances (7 repos: Exercise/PerformedExercise/PersonalRecord/Session/Set/TrainingExercise/Training + ResourceWrapper + Navigator + StoreDispatchers + Analytics/Logger + @DefaultDispatcher), 2 @BINDS. LiveWorkoutGraph @DependencyGraph(scope=LiveWorkoutScope) exposing the assisted LiveWorkoutStoreImpl.Factory (never the Store); LiveWorkoutHiltEntryPoint via EntryPointAccessors; Store assisted, unscoped; delete LiveWorkoutModule. App untouched. INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 13 bridged singletons + feature-scoped nodes wired. Map-match confirmed: 0 Context, @default only. FULL :feature:live-workout:testDebugUnitTest (per the largest-feature requirement): 158 tests, 0 failures, 0 errors — ALL green. Gates all EXECUTED: compile (codegen real incl LiveWorkoutStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (12 features Metro), full testDebugUnitTest, detekt green. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…rd topology, Context Hilt-side) Wave 4 dedicated slot — the third mount topology (AppFeature: root-mounted, Activity-scoped ViewModelStore) with a secondary-constructor Context. DISCOVERY-FIRST resolved both unknowns to a KNOWN mechanic before flipping (decision gate → PROCEED): UNKNOWN 1 (AppFeature mount): the Metro seam applies AS-IS. AppFeature.createProcessor delegates to the SAME no-Screen rememberStoreProcessor overload plain Feature uses; only the MOUNT SITE differs (Activity ViewModelStore vs NavBackStackEntry), which is a caller concern (AppDialogHost), unchanged by the DI flip. AppDialogStoreImpl is a PLAIN @hiltviewmodel → graph exposes the Store directly (archive/all-exercises template). rememberMetroStoreProcessor retains it in whatever LocalViewModelStoreOwner is current — the Activity's, at the root mount. UNKNOWN 2 (secondary-ctor Context): DISSOLVES. AppDialogRepository is an app-scoped @singleton that HILT constructs (via its secondary @Inject ctor with @ApplicationContext Context, on the Hilt side, already working). It is BRIDGED into the Metro graph as a bound instance like any @singleton. Metro NEVER constructs it → never sees the secondary ctor → no ctor-selection question, no 3-point Context mechanic in the Metro graph. The Context stays 100% Hilt-side. Flip = ViewModel tier only. AppDialogGraph @DependencyGraph(scope=AppDialogsScope) exposing the Store directly; AppDialogsHiltEntryPoint bridges 5 @singletons (AppDialogRepository + AppDialogObserverImpl [the concrete — ChooseHandler injects the impl, not the interface] + StoreDispatchers + Analytics + Logger); 1 @BINDS (AppDialogHandlerStore); NO dispatcher, NO Context in the graph. 2 handlers @singlein; HandlerStore + Store as usual. Removed only the ViewModel half of the module; the SingletonComponent @BINDS (AppDialogPublisher / AppDialogObserver) stay in Hilt. API CONTRACT PRESERVED: app-dialogs/api untouched; the SingletonComponent Publisher/Observer binds untouched. Consumers (settings BackupClickHandler → AppDialogPublisher; recovery RestoreDialogChoiceObserver → AppDialogObserver; app BaseApplication reactor) still wire — proven by the clean assembleDebug. INTEGRATION PROOF (AppDialogGraphBridgeTest, real graph, 2/2): plain graph constructs the Store; bridged singletons reach it by === identity. Gates all EXECUTED: compile (codegen real AppDialogGraph$Impl/$Factory$Impl/$BindsMirror), clean :app:dev:assembleDebug (13 features Metro; api consumers intact through @HiltAndroidApp merge), full testDebugUnitTest, detekt green. NO new config exemption. Bisect-green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
… (leaf) Stand up factory-shaped Metro AppGraph alongside Hilt (app-scope dual-path). Migrate AnalyticsHolder to Metro single-owner (strip @Inject/@singleton); Hilt-side readers resolve via delegating @provides reading AppGraph from a Hilt binding via AppGraphOwner (never casting Application — HiltTestApplication does not extend BaseApplication). @HasMemberInjections on BaseApplication for Metro coexistence. Prod + test AppGraph via @TestInstallIn. E proven: === across Metro-direct and Hilt-inject-via-adopt-back paths, on emulator under @HiltAndroidTest; negative control (BaseApplication cast) confirmed throwing ClassCastException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2, additive) Design B (plain bridge factory, not Metro assisted-injection): MetroWorkerFactory implements androidx WorkerFactory, bridge-reads BackupWorker's 6 deps from Hilt via a new BackupWorkerHiltEntryPoint (SettingsHiltEntryPoint bound-instance pattern) and constructs BackupWorker directly. Metro never processes BackupWorker's constructor; @HiltWorker + Dagger @AssistedInject stay untouched. Reuses the batch-bridge pattern (Metro-reads-Hilt), not M3 adopt-back — BackupWorker is framework-constructed, not graph-constructed. Design A (Metro @AssistedInject on BackupWorker) rejected: double-processor collision with Dagger @AssistedInject/@HiltWorker (would force @HiltWorker drop into Step 2), and Metro assisted types cannot be scoped (wrong for a framework-constructed worker). BackupWorkerHiltEntryPoint is transient scaffolding — retired at Step 6 when the Configuration.Provider flip to MetroWorkerFactory removes all bridges. Factory is DORMANT: zero main references, Configuration.Provider still returns Hilt's HiltWorkerFactory, no runtime behavior change. Both in core/data/backup/worker (BackupWorker is internal). Plain-instantiate at Step 6 (Context-only ctor) — no AppGraph binding, no TestAppGraphModule mirror. Verified vs Metro 1.1.1 + androidx.work 2.10.0: null-fallthrough sufficient (WorkManager's createWorkerWithDefaultFallback handles reflection); FQN class-match mirrors HiltWorkerFactory. Known-positive (constructs BackupWorker, 6 accessors invoked) + known-negative (null, bridge untouched via != short-circuit) tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…s (Step 3 SB1 slice) App-Scope Collapse Step 3 (bulk mechanical batch), first proven slice. Establishes the mechanic for migrating app-scoped Hilt @singletons to Metro-owned, and resolves a blocker the spec's 'app/app @provides' model hit: ~13 of the bulk impls are internal to their core module, and the internal AppGraph in app/app cannot reference them. Mechanic (visibility-respecting): @ContributesBinding(AppScope) + @singlein(AppScope) + @Inject ON THE IMPL in its own module. Metro's @DependencyGraph(AppScope::class) auto- aggregates all AppScope contributions, so app/app never references the internal impl. Verified against Metro 1.1.1 (DependencyGraph auto-aggregation). This slice migrates NumUiUtils (a clean binding — no app-scope Hilt consumer, so no adopt- back @provides needed) and stands up the shared prerequisites: - AppScope declared in core:core-android (an Android-only com.android.library) and public. NOT in core:core: that is a live KMP module whose commonMain compiles to iosSimulatorArm64, so an app-scope DI marker there would leak Android-DI scaffolding into the iOS binary. core: core-android is Android-only and low in the graph (depends only on core:core), so every bulk contributor can depend on it without a cycle; it shares the package with the dispatcher qualifiers, so consumer imports are unchanged. - Metro plugin + interop { includeJavax() } applied to core:ui:kit (composeLibrary+metro is the proven combo used by the flipped features); core:ui:kit gains a core:core-android dep. - NumUiUtilsImpl: Hilt @Inject/@singleton stripped, its @BINDS removed from CoreUiKitModule, @ContributesBinding/@SingleIn/@Inject added. - AppGraph: no per-binding @provides — the graph auto-merges the contribution; a numUiUtils accessor is kept for identity tests. DORMANT/dual-path intact: @HiltAndroidApp untouched; Hilt still owns every not-yet-migrated binding. Reversible via git revert. :app:dev:assembleDebug + detekt green; core:core still compiles for iosSimulatorArm64 (AppScope no longer in the iOS surface). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
… AppScope (Step 3 B.1) App-Scope Collapse Step 3 false-green guard. Metro's @ContributesBinding(scope = KClass<*>) accepts any class, so a wrong scope compiles green but the binding silently fails to aggregate into the app-scope AppGraph (@DependencyGraph(scope = AppScope::class)) — absent at runtime with no compile signal. Same soundness class as the pre-d1cb7965 HiltScopeRule gap (which validates @singlein scope on Handlers only and has zero @ContributesBinding coverage). ContributesBindingScopeRule flags any @ContributesBinding whose scope arg is not the project AppScope (io.github.stslex.workeeper.core.core.di.AppScope). Two failure modes caught: - scope simple name != AppScope (a feature scope, typo, other marker); and - AppScope imported from dev.zacsweers.metro — Metro's BUILT-IN app scope, same simple name but a different class from the project token the AppGraph is scoped to, so a contribution to it would not aggregate. PSI has no type resolution, so the import origin discriminates. Proven on known-NEGATIVE anchors (wrong scope, Metro's built-in AppScope, missing arg — all compile green today, all now flagged) AND known-POSITIVE (project AppScope passes). The existing NumUiUtilsImpl (SB1, correct AppScope) is not flagged; :app:dev:assembleDebug + full lint-rules suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…p 3 D2) Repairs a pre-existing latent defect: AppGraphAdoptBackModule.provideAppGraph did (context as AppGraphOwner).appGraph. Prod BaseApplication implements AppGraphOwner, but the Hilt instrumented harness swaps in HiltTestApplication, which does not — so the cast threw ClassCastException in every flavor @HiltAndroidTest resolving a migrated binding at startup. app:dev / app:store run their own HiltTestApplication + Regression suites and (being separate modules that can't see app:app internals) could not @TestInstallIn-replace the module. The leaf/SB1 === was only ever validated in app:app's own androidTest — where a whole-module @TestInstallIn replaced AppGraphAdoptBackModule with a HAND-COPIED double, so the === proved the copy, not the production shim. The flavor Regression suites were RED. Fix (Option 3 — isolate the cast + fallback-build): - New AppGraphSourceModule: the SINGLE provider of the AppGraph binding. It returns the BaseApplication-held graph when the Application is an AppGraphOwner (prod), else builds the REAL AppGraph from applicationContext (test) — the same createGraphFactory(...).create(...) BaseApplication performs, so zero test-double drift and no cast crash. No per-flavor test wiring: all three flavors' HiltTestApplication tests work unchanged. - AppGraphAdoptBackModule now holds ONLY the adopt-back shims (unchanged bodies), consuming the AppGraph binding. It is NEVER @TestInstallIn-replaced, so tests exercise the REAL shims. - app:app's TestAppGraphModule now @TestInstallIn-replaces ONLY AppGraphSourceModule (not the whole adopt-back module), so its own seam test also exercises the real shim now. GATE (upgraded): app:dev:assembleDebug green AND the flavor instrumented Regression suite runs GREEN on-device — 6/6 (ApplicationBottomBarTest.appStartInitial, which crashed at baseline, now passes). app-assemble is green-blind to the runtime cast; the on-device flavor Regression gate is mandatory for every adopt-back commit from here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…1 mvi slice) + seam proof App-Scope Collapse Step 3, first ADOPT-BACK SHIM + COLLIDER slice (NumUiUtils was clean — no shim, no collider). core:ui:mvi gains the Metro plugin + includeJavax. - LoggerHolder + StoreDispatchers: Hilt @Inject/@singleton stripped; now @singlein(AppScope) @Inject. NOT @ContributesBinding — both are concrete self-bound classes with no supertype, and @ContributesBinding binds to a supertype (verified against Metro 1.1.1). Instead the AppGraph exposes val loggerHolder / val storeDispatchers accessors, pulling the scoped @Inject classes into the graph as retained singletons. - Adopt-back shims (provideLoggerHolder / provideStoreDispatchers) in AppGraphAdoptBackModule delegate to the graph accessors — single-owner, the 13 *HiltEntryPoint readers + BaseStore ctor param resolve through them. 13 readers = 1 @provides each. - COLLIDER: StoreDispatchers ctor-needs @DefaultDispatcher + @MainImmediateDispatcher (still Hilt-owned this layer), bridged into AppGraph.create() as qualified bound instances (includeJavax carries the qualifiers). AppGraphSourceModule (prod) + BaseApplication pull them from Hilt via a DispatcherBridgeEntryPoint; retired when core:core-android migrates. SEAM PROOF (on-device, the point of this slice — NumUiUtils skipped it): - POSITIVE: LoggerHolder resolved via a Hilt EntryPoint AND the Metro graph accessor → SAME instance (=== not ==) through the REAL shim (not a test double, post-D2-decouple). - NEGATIVE CONTROL: casting Application→BaseApplication still throws ClassCastException under HiltTestApplication — preserved, shown failing (guards against false-green). - COLLIDER: both dispatchers resolve with correct qualifier, === the Metro-owned instance. GATE: :app:dev:assembleDebug + detekt green; app:app seam suite 6/6 on-device; the flavor (app:dev) instrumented Regression 6/6 on-device (ApplicationBottomBarTest resolves the migrated bindings at Home startup through the real shims). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
… (Step 3 B3 hygiene) Two folded cleanups (from the B2-verify pass), before the ImageStorage migration multiplies the create() param surface. V.2 — single construction site: extract buildAppGraph(applicationContext, dispatchers...) that BOTH BaseApplication.appGraph and AppGraphSourceModule's test fallback call. Previously each threaded the create() argument list independently — a two-place sync obligation that would grow with every bridged input (dispatchers now, DAOs later). Now a new create() param is added in one place. No behavioral change: same factory call, same graph. V.1 — retarget the negative control: the decouple replaced the Application cast with an check, so the old assertion no longer mirrored production. Retargeted to assert HiltTestApplication !is AppGraphOwner — the actual invariant the fallback branch depends on (if the test app WERE an owner, the real-graph-build branch would never run: a false-green). Still a real tripwire. GATE: :app:dev:assembleDebug + detekt green; app:app seam suite 6/6 on-device (incl. the retargeted negative control); app:dev flavor Regression 6/6 on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…ributesBinding + shim) App-Scope Collapse Step 3. Closes the previously-unproven mechanic cell: @ContributesBinding (interface-bound) WITH an adopt-back shim, end-to-end on-device. Resolves D1 per the B4 spike: cross-Gradle-module @ContributesBinding requires a PUBLIC impl. Metro 1.1.1 rejects an internal contribution the app graph would extend from another module (MergedContributionChecker.isInternalInFriendModule); @PublishedApi and both @ContributesTo- container shapes also fail — only public aggregates. So BackupPreferencesRepositoryImpl is widened internal->public. The widening is narrow: its override methods were already public via the interface; only the class + constructor visibility change, no behavior change. - BackupPreferencesRepositoryImpl: Hilt @Inject/@singleton stripped -> @ContributesBinding(AppScope) + @singlein(AppScope) + @Inject; @ApplicationContext dropped -> plain Context (Metro constructs it, resolving Context from the graph's create(applicationContext) bound instance). Its @BINDS removed from BackupSchedulingModule (RestoreStateRepository's @BINDS stays). - core:data:backup:scheduling gains the Metro plugin + includeJavax + core:core-android dep (AppScope). - AppGraph: backupPreferencesRepository accessor. AppGraphAdoptBackModule: one adopt-back @provides delegating to it (single-owner). SettingsHiltEntryPoint + BackupWorkerHiltEntryPoint resolve through it. ContributesBindingScopeRule (e177a19) fired live for the first time on real code and passed (correct project AppScope). Seam proof on-device (app:app 7/7): backupPreferencesRepository === positive via BOTH Hilt EntryPoint readers (Settings + Worker) resolving the Metro-owned instance; negative control holds. NOTE the Worker leg is a plain Hilt EntryPoint (Step-2 MetroWorkerFactory dormant) — NOT a live Metro-bridge cross-check. app:dev flavor Regression 6/6 on-device (appStartInitial resolves the migrated binding through the real shim). detekt + :app:dev:assembleDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…atable @ContributesBinding) App-Scope Collapse Step 3, FLIP-A batch L0 — first multi-supertype binding. ActivityHolderImpl implements TWO interfaces (ActivityHolder + ActivityHolderProducer). Metro's @ContributesBinding is @repeatable → applied twice with explicit binding<ActivityHolder>() / binding<ActivityHolderProducer>() (the ambiguity-resolving form for a multi-supertype impl, verified in the Metro 1.1.1 clone). One @singlein(AppScope) ActivityHolderImpl backs both types. - Hilt @Inject/@singleton stripped (impl already public — no widening). Both @BINDS removed from CoreUiKitModule (ResourceManager @BINDS kept — it's L1, migrates later). - AppGraph: activityHolder + activityHolderProducer accessors. AppGraphAdoptBackModule: one adopt-back @provides per type, both delegating to the same graph-owned instance. MANIFEST CORRECTION caught at migration time: the manifest listed ActivityHolder shim=0, but ResourceManagerImpl (still-Hilt, L1) injects ActivityHolder — so it DOES need a shim. Both types shimmed (ActivityHolder<-ResourceManagerImpl, ActivityHolderProducer<-MainActivity). Shim consumers are re-verified per-binding, not batch-trusted. ContributesBindingScopeRule passed on BOTH repeatable @ContributesBinding (first repeatable firing). Seam proof app:app 8/8 on-device (activityHolder test: both bound types + both Hilt shims === the ONE impl). app:dev flavor Regression 6/6 on-device. detekt + :app:dev:assembleDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…zer to Metro (Step 3 L0) App-Scope Collapse Step 3, FLIP-A L0 — core:core-android platform providers (3 interface-bound impls). core:core-android gains the Metro plugin + includeJavax (it already DECLARES AppScope, so no extra dep; Metro coexists with its Hilt CoreModule dispatchers). Each impl: Hilt @Inject/@singleton stripped, widened internal->public, @ContributesBinding(AppScope)+@singlein+@Inject, @ApplicationContext dropped to plain Context. PlatformModule (all 3 @BINDS migrated) deleted. 3 AppGraph accessors + 3 adopt-back @provides. Shim consumers (re-verified per-binding, not manifest-trusted): PlatformInfoProvider<-RestoreRecoveryCoordinator +SettingsHiltEntryPoint; TempFileProvider<-SettingsHiltEntryPoint (manifest said 0 — corrected); AppReinitializer<-NavigatorEventBus+RestoreRecoveryCoordinator. detekt + assembleDebug green. Seam 9/9 on-device (platformBindings test: all 3 Hilt adopt-back === Metro). app:dev flavor Regression 6/6 on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
App-Scope Collapse Step 3, FLIP-A L0 — core:data:backup:scheduling RestoreStateRepository. Interface-bound: Hilt @Inject/@singleton stripped, widened internal->public, @ContributesBinding(AppScope)+@singlein+@Inject, @ApplicationContext dropped to plain Context. BackupSchedulingModule deleted (empty — both its bindings, BackupPreferences + RestoreState, now Metro). AppGraph accessor + adopt-back @provides. Shim consumers (re-verified): RestoreDialogChoiceObserver + RestoreRecoveryCoordinator (recovery Hilt @singletons) + SettingsHiltEntryPoint. detekt + assembleDebug green (fixed a MaxLineLength on the shim KDoc). Seam 10/10 on-device. app:dev flavor Regression 6/6 on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
The port in 8f2fb43 left SingleTrainingDeps present-but-unused so that SHA stayed independently valid. Nothing reads it now, so it goes with its entry in AppGraph's supertype list. THREE accessors orphaned — the biggest batch so far, and the first deletion where a count would have been badly wrong: * @MainImmediateDispatcher — SingleTrainingDeps was its last declarer. Live root: both SingleTrainingExtensionIdentityTest and ExerciseExtensionIdentityTest read it to assert the qualified pair stays distinct. * tagRepository — last declarer, no remaining reader, genuinely inert for now. * sessionConflictResolver — last declarer. Live root: the single-training identity test reads it to assert the extension inherits the parent's instance rather than building its own double. All three become plain `val`s with the reason recorded; deletion stays deferred to the final cleanup commit. Running ledger: past-session orphaned 1, exercise-chart 0, exercise 1, single-training 3. That is 5 orphans from 4 deletions with no pattern predicting the count — which is exactly why the cleanup commit must ENUMERATE what remains rather than assume, and why "orphaned" must not be read as "dead": 4 of the 5 are still live roots. 6 XxxDeps supertypes -> 5, counted by reading the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…y its own test case FORCED-PUBLIC: predicted 23, measured 23 (prediction committed cf21dbd before round 1). Widest port of the arc. All five side-claims held. 23 forced, 0 over-widened, 0 stale, control green first. One identity assertion had to be rewritten before it meant anything, and it was GREEN while it was meaningless. The session-subsystem test first read assertSame(appGraph.sessionConflictResolver, appGraph.sessionConflictResolver) comparing the parent's accessor to ITSELF. It never touched the extension and would pass regardless of what the extension resolved. A self-comparing assertion is the adjacent-answer class in test form: it answers "is this accessor stable" instead of "did the extension inherit it", and both readouts are a green tick. Fixed by adding a sessionConflictResolver observability accessor to the extension, and proven RED against a DIFFERENT AppGraph's resolver. BINDING MECHANISM: REFUTED. The prediction logged in cf21dbd said ~+0.10 confirms and ~+0.03 kills. N=11 (single-training, 13 bindings) came in at +0.018s. Killed — by the port chosen in advance to test it, against a number written before the port existed. exercise-chart N=9 8 bindings +0.027 past-session N=8 9 bindings +0.060 single-training N=11 13 bindings +0.018 exercise N=10 14 bindings +0.115 Monotonic in bindings: no. Monotonic in N: no. single-training and exercise are ADJACENT binding counts whose deltas differ 6x, which is exactly what the hypothesis forbade. The n=3 monotonicity was coincidence; three points can be ordered by many variables and the fourth killed it. WHAT SURVIVES, and a flaw in how it was measured. All four deltas are positive (sign test p = 1/16), mean +0.055s, cumulative N=7->N=11 +0.220s. But in every one of the four pairs the NEW row was measured FIRST and the control SECOND — a fixed order, not an alternating one. Any systematic within-session drift therefore biases every delta the SAME way: if the machine speeds up as a session proceeds, the first-measured row is systematically slower and every delta is biased positive. Four positive deltas is exactly that artefact's signature. Reversed order protects the N axis from cross-row drift; it does nothing about within-pair order bias when the order never changes. So "all four positive" is NOT evidence of a per-extension cost — it is equally well explained by the order that produced it. Fix for future pairs: alternate the order between ports, or bracket (control, new, control). The binding-axis re-run is moot, the axis being refuted. A full single-session re-baseline is now MORE valuable, as the only way to separate a small genuine per-extension cost from a fixed-order artefact — and it must use alternating or bracketed order, not the fixed new-first order used so far. Status: 11 of 13 ported, 5 XxxDeps supertypes, StoreFactory down to 1 user. live-workout is the last feature graph — port 13 — so the ENDPOINT build-time reading is taken there, per the rule logged in cf21dbd. 8f2fb43 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…live-workout's prediction
Timestamped ahead of the port it judges. No source file touched.
WITNESSES 11-13 recorded. 12 and 13 are the two worst so far, because each was created
by a fix for an earlier one:
11. Stale case regex in the falsifier — the mutation never applied because the real
declaration carried its supertype inline. Caught only because the harness verifies
the edit landed and refuses to score un-applied cases.
12. Fixed within-pair measurement order. Reversed order was introduced to stop
cross-row drift faking an N-slope, and it does that — but the order INSIDE each
pair was then identical every time, so any within-session speedup biases every
delta positive. Four positive deltas were that artefact's fingerprint, not a
per-extension cost. A fix for a confound is itself a design decision that can
introduce one: re-run the recognition test on the FIX, not just on what it fixed.
13. Self-comparing identity assertion — assertSame(parent.x, parent.x) inside the very
instrument that proves extension correctness.
IDENTITY-TEST AUDIT: no other instance. Every other assertSame across the eleven
identity tests has one operand from the extension or the store. Two look similar and
are correct — ImageViewer's assertNotSame(a, b) compares two extension-built stores, and
AppGraphIdentityTest's assertSame(first, second) compares two reads of
graph.analyticsHolder, parent-side on both sides but making a genuinely parent-side
stability claim.
The lesson generalises: assertSame(parent.x, parent.x) is VALID for a stability claim
and VACUOUS for an inheritance claim. Identical code, different claim — so this defect
cannot be found by pattern matching, only by reading each assertion against the sentence
it is meant to prove.
BRACKETING replaces alternating as the fix for witness 12. Alternating between ports
merely averages the bias out and never shows it; bracketing (control, new, control)
measures within-pair drift directly as the gap between the two controls, so it can be
reported and subtracted. After two hidden confounds, a design that SHOWS drift beats one
that blurs it.
LIVE-WORKOUT PREDICTION: 24 = 6 plumbing + 2 interactor + 15 domain + 1 UI. Would be the
widest port of the arc. Domain 15 is every declaration; the closure runs four levels down
the SessionSnapshotDomain spine, the deepest chain in the arc.
Six falsifiable side-claims, the sharpest being that BottomSheetState costs ZERO while
DialogState costs 1 — two sibling state types in the same directory, both reachable from
State, differing only in declared visibility. Also: ErrorType and LiveSessionStateUiModel
stay internal, nested DraftKey/PrSnapshotItem need no edits, no sealed subtype needs one,
and all 7 handlers plus 5 mappers stay internal (the largest handler set in the arc).
This port carries the ENDPOINT build-time reading per the port-13 rule, measured with
BRACKETED order. Prediction for the bracket: the two control readings will differ and the
drift-corrected delta will be smaller than the raw one. Controls differing in the
inflating direction confirms witness 12; controls agreeing closely while the delta
persists puts a small real per-extension cost back on the table.
Measuring BELOW 24 is a STOP, not a win.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…sion (shape B, 13/13) THE LAST FEATURE GRAPH. LiveWorkoutGraph becomes a @GraphExtension(LiveWorkoutScope) whose Factory carries @ContributesTo(AppScope). The 13 hand-threaded bound instances are gone. With this port StoreFactory has NO users left and dies with the closing commits. FORCED-PUBLIC: predicted 24, measured 24 — the widest port of the arc. Prediction committed in 92230fd BEFORE the first widening round. 6 plumbing + 2 interactor + 15 domain + 1 UI Domain 15 is every domain declaration. Signatures gave nine; closure added six, almost all down the SessionSnapshotDomain spine — .session -> SessionDomain -> .state -> SessionStateDomain, and .exercises -> LiveExerciseDomain -> .performed -> PerformedExerciseDomain / .performedSets -> SetDomain, plus PersonalRecordDomain.type -> SetTypeDomain. Four levels from one signature type, the deepest closure chain in the arc, and the compiler walked it in exactly the predicted order. All six side-claims held. The sharpest is the first: BottomSheetState cost ZERO while DialogState cost 1. Two sibling sealed state types, same directory, both reachable from State, differing only in declared visibility — the clearest demonstration available that the UI term is about how a type happens to be declared, not about what kind of thing it is. Fifth port, fifth UI cost (0, 6, 1, 1, 1). Also held: ErrorType and LiveSessionStateUiModel stayed internal, nested DraftKey/PrSnapshotItem needed no edits, no sealed subtype needed one, and all 7 handlers plus 5 mappers stayed internal — the largest handler set in the arc and the strongest test yet of the internal-constructor mechanism. All 24 falsified individually: 24 forced, 0 over-widened, 0 stale. Known-negative control (LiveWorkoutScope) validated for this feature and green before any positive. STANDING RULE 4: this was the final boundary candidate — it owns the session WRITE path (start, add-exercise, finish, cancel, discard-adhoc) and inherits the deepest transactional stack of any extension. Construction succeeds off-device, so the direct claim is made. NO feature in the arc turned out to need the boundary form; the rule stays on the books for MetroWorkerFactory, which is not a feature graph. IDENTITY TEST: six claims, all green, and every assertSame has one operand from the EXTENSION — deliberately, after witness 13. Two anchors proven RED, each breaking only its own test: comparing the session repository against a DIFFERENT AppGraph fails the write-path test, and swapping the graph's dispatcher qualifier fails the dispatcher test. The route arg is the widest in the arc — TWO nullable uuids of which at least one is non-null — so both legal arms are asserted separately. A bound instance of a nullable type is where a graph could substitute a default, and here that would silently pick the wrong entry mode. ScreenInjectionRule: Screen.LiveWorkout is a flat 2-level data class, the already-proven shape. detekt executed (not FROM-CACHE), zero suppressions. LiveWorkoutDeps is left present-but-unused so this SHA is independently valid; deleted in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
… feature bridge gone The port in ee95937 left LiveWorkoutDeps present-but-unused so that SHA stayed independently valid. Nothing reads it now, so it goes with its supertype entry. This is the LAST feature-graph bridge interface. AppGraph's supertype list is now StoreCoreDeps, NavigatorDeps, RecoveryDeps, BackupWorkerDeps — and of those only the first two are the load-bearing gamma-spine the arc always said it bottoms out at. RecoveryDeps and BackupWorkerDeps are not feature-graph bridges. NINE accessors orphaned, by far the biggest batch: defaultDispatcher, resourceWrapper, exerciseRepository, sessionRepository, setRepository, personalRecordRepository, performedExerciseRepository, trainingExerciseRepository, trainingRepository. All become plain `val`s with `override` dropped; none is deleted, because the bulk accessor cleanup is the second closing commit. That completes the orphan ledger and settles the enumeration rule empirically: past-session 1, exercise-chart 0, exercise 1, single-training 3, live-workout 9 Fourteen orphans from five deletions, with no pattern predicting the next — the last one alone is nine, after three deletions that produced two between them. Any cleanup driven by an assumed count would have been badly wrong here. And "orphaned" still does not mean "dead": several are live roots — imageStorage via BaseApplication.cleanupOrphanedImageTempFiles, and sessionConflictResolver, the dispatchers and the repositories via the :app identity tests. The ledger and that conclusion are recorded as a comment on AppGraph itself, where the cleanup commit will be written, rather than only in the arc HANDOFF. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…nd a count correction FORCED-PUBLIC: predicted 24, measured 24 (prediction committed 92230fd before round 1). Widest port of the arc. All six side-claims held, including the sharp one: BottomSheetState cost ZERO while its sibling DialogState cost 1 — two sealed state types in the same directory, both reachable from State, differing only in declared visibility. Fifth port, fifth UI cost (0, 6, 1, 1, 1). 24 forced, 0 over-widened, 0 stale. BRACKETED BUILD-TIME READING — the first taken as control, NEW, control: control A (1st) N=11 1.004 [0.965, 1.143] NEW (2nd) N=12 0.989 [0.967, 1.133] control B (3rd) N=11 0.981 [0.915, 1.130] within-pair drift (ctlB - ctlA) : -0.023s MEASURED, not assumed raw delta (new - ctlA) : -0.015s drift-corrected : -0.003s three-way ranges : OVERLAP Two findings, both produced by the bracket. 1. Adding the twelfth extension costs nothing measurable: -0.003s corrected, ranges overlapping. The earlier positive deltas are not a compounding per-extension cost. 2. WITNESS 12 IS CONFIRMED, quantitatively. The bracket measured the machine getting FASTER within the pair (-0.023s). That is exactly the direction that inflates a new-measured-first delta: the new row ran on the slower early machine and its control on the faster later one, biasing new-minus-control positive by roughly the drift magnitude. The four earlier deltas (+0.018, +0.027, +0.060, +0.115, mean +0.055) minus a ~0.023 order bias puts three of four at or below the 0.045 yardstick. The prediction in 92230fd — controls differ, corrected < raw, drift in the inflating direction — landed on all three counts. CORRECTION, and it is mine. live-workout is port 12 of 13, NOT 13 of 13. Commit ee95937 is mislabelled "13/13" and "THE LAST FEATURE GRAPH" in both subject and body. It is the last ASSISTED feature and the last feature-graph bridge interface, but app-dialogs is still a @DependencyGraph and is genuinely the thirteenth. Measured: 12 @GraphExtension, 1 @DependencyGraph remaining. I inherited "port 13" from the brief instead of grepping — the count class again, committed inside the arc that carries a "grep, not memory" inventory section, and after the same class had already been recorded twice. Not rewritten, per no-rebase; the correction lives in Status and in the endpoint section. CONSEQUENCE: the ENDPOINT reading has not been taken. The bracketed reading above is N=11 -> N=12. The true endpoint is N=13 and, per the port-13 rule, must be measured as part of the app-dialogs port, before the closing commits. Status: 12 of 13 ported, 4 supertypes left on AppGraph (only StoreCoreDeps + NavigatorDeps are the arc's gamma-spine; RecoveryDeps and BackupWorkerDeps are not feature bridges), StoreFactory has NO users left. All seven FeatureAssisted users are done. app-dialogs is the only remaining feature graph. ee95937 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,12,12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
Surveyed before touching source, per the instruction to verify shape B applies rather than assume the assisted pattern transfers. It applies, with one real divergence that is a deletion opportunity rather than an obstacle. TRANSFERS UNCHANGED. No route arg means the NO-ARG factory form, createAppDialogGraph() with zero @provides — not new, it is exactly what the five plain Feature<P,S> ports used, so shape B's no-arg variant is already proven 5x. And the Store is ALREADY plain @Inject: AppDialogStoreImpl was never assisted, so there is no assisted machinery to strip. Smallest graph in the arc — 5 factory params, 1 @BINDS, 2 handlers. THE DIVERGENCE: this port kills a SECOND seam. app-dialogs/impl carries AppDialogInternalsHolder + Context.appDialogInternals(), implemented by BaseApplication, which exists because AppDialogRepository and AppDialogObserverImpl are impl-owned concrete types no other module can name — so they reach the feature graph through an Application-implements-holder trick instead of appDeps<T>(). Once app-dialogs is a contributed @GraphExtension that seam is dead: the extension inherits every app-scoped binding from AppGraph, which already exposes both types as accessors, and the seam's only consumer is AppDialogFeature. So this port also removes the holder interface, the accessor function, and BaseApplication's supertype plus two get() overrides — the analogue of an XxxDeps deletion, but of a different seam type. WHAT IS NOT AFFECTED, so the closing commits do not over-delete: AppDialogPublisherHolder + Context.appDialogPublisher() in the API module STAYS. It serves cross-module consumers (settings / recovery / archive), unrelated to this feature's own graph. Two similarly-named seams, only one dead. Also unaffected: app-dialogs is the only feature that CONTRIBUTES app-scoped bindings (AppDialogRepository is @singlein(AppScope), the observer and publisher are @ContributesBinding) — the port touches only its feature-scoped graph. PREDICTION: 6 = 6 plumbing + 0 interactor + 0 models. The smallest port of the arc, tying image-viewer, and a bookend — the widest (live-workout, 24) immediately before the narrowest. Six falsifiable side-claims: zero interactor pairs (no interactor exists; the domain layer is one internal object); zero models (AppDialog / AppDialogUserAction / AppDialogUserChoice live in the api module and are already public cross-module); AppDialogResolver and AppDialogKeys stay internal; both handlers stay internal; AppDialogsScope stays internal; and AppDialogRepository / AppDialogObserverImpl need no visibility change at all, being inherited rather than exposed. Measuring BELOW 6 is a STOP, not a win. This port carries the ENDPOINT reading (N=13), bracketed, cold-calibration logged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…ion (13/13 — ALL PORTED) THE THIRTEENTH AND LAST FEATURE GRAPH. Every feature graph in the repo is now a contributed @GraphExtension; no @DependencyGraph remains under feature/. AppDialogGraph becomes a @GraphExtension(AppDialogsScope) whose Factory carries @ContributesTo(AppScope). The 5 hand-threaded bound instances are gone. SHAPE, surveyed before porting (4efb90d) rather than assumed. app-dialogs is screen-less (AppFeature<P>, not FeatureAssisted), so the creator takes NO arguments: createAppDialogGraph(). That is not a new shape — it is exactly the no-arg form the five plain Feature<P,S> ports used. And its Store was already plain @Inject, so there was no assisted machinery to strip. The pattern transferred with nothing invented. THE REAL DIVERGENCE, and it is a deletion: this port retires the AppDialogInternalsHolder seam. AppDialogRepository and AppDialogObserverImpl are impl-owned concrete types no other module can name, so they used to reach the feature graph through an Application-implements-holder trick (Context.appDialogInternals()) instead of appDeps<T>(). As a contributed extension the graph inherits both straight from AppGraph, so the seam has no consumer left. The holder, its accessor and BaseApplication's two get() overrides go in the next commit, per the port-then-delete split. NOT affected, so the closing commits do not over-delete: AppDialogPublisherHolder + Context.appDialogPublisher() in the API module STAYS — it serves cross-module consumers (settings / recovery / archive) and has nothing to do with this feature's own graph. Two similarly-named seams; only one is dead. FORCED-PUBLIC: predicted 6, measured 6 — the NARROWEST port of the arc, tying image-viewer, immediately after the widest (live-workout, 24). Prediction committed in 4efb90d before the first widening round. 6 plumbing + 0 interactor + 0 models All six side-claims held: no interactor exists (second port ever with 0 pairs); zero models, because AppDialog / AppDialogUserAction / AppDialogUserChoice live in the api module and are already public cross-module; AppDialogResolver and AppDialogKeys stayed internal; both handlers stayed internal; AppDialogsScope stayed internal; and AppDialogRepository / AppDialogObserverImpl needed no visibility change at all, being inherited rather than exposed. All 6 falsified individually: 6 forced, 0 over-widened, 0 stale. Control (AppDialogsScope) validated for this feature and green first. IDENTITY TEST: four claims, all green. Two are specific to this port. The seam-retirement claim asserts both app-scoped singletons are the PARENT's instances — that is what proves the internals holder is genuinely redundant rather than merely unused, since a double would fork app-root dialog state from what the rest of the app publishes. Proven RED against a different AppGraph. The second replaces the per-route-arg claim the other ports make: there is no route arg, so the property pinned is that the no-arg creator still builds a DISTINCT extension and Store per call (the extension is not itself a singleton) while the INHERITED app-scoped repository stays one instance across both. detekt executed (not FROM-CACHE) on :feature:app-dialogs:impl and :app:app, zero suppressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…e app-dialogs port The port in 5fafe04 left the seam present-but-unused so that SHA stayed independently valid. Nothing reads it now, so it goes: the holder interface, the Context.appDialogInternals() accessor, BaseApplication's supertype and its two get() overrides. This is the app-dialogs analogue of an XxxDeps deletion, but of a DIFFERENT seam type. AppDialogInternalsHolder existed because AppDialogRepository and AppDialogObserverImpl are impl-owned concrete types that no dep interface can name, so they reached the feature graph through an Application-implements-holder trick rather than appDeps<T>(). As a contributed extension the graph inherits both straight from AppGraph, which already exposed them — so the trick has nothing left to do. That inheritance is not assumed: AppDialogExtensionIdentityTest asserts both singletons are the PARENT's instances, and that assertion is proven RED against a different AppGraph. A double would have forked app-root dialog state from what the rest of the app publishes, and the seam could not have been deleted. AppDialogPublisherHolder in the API module is deliberately UNTOUCHED. It is a similarly-named but different seam serving cross-module consumers (settings / recovery / archive reading the publisher from a Context), and nothing about this port makes it redundant. Recorded here because the closing commits must not over-delete it. Also drops a now-false doc reference in TestApplication, which still listed AppDialogInternalsHolder among the interfaces BaseApplication implements. No accessor was orphaned: AppGraph's appDialogRepository / appDialogObserverImpl are plain vals already (this feature contributes app-scoped bindings rather than bridging them), so the orphan ledger stays at 14 from five XxxDeps deletions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…sts nothing FORCED-PUBLIC: predicted 6, measured 6 (prediction committed 4efb90d before round 1). Narrowest port of the arc, tying image-viewer, landing immediately after the widest (live-workout, 24). All six side-claims held — no interactor exists, zero models because the api module already owns them publicly, resolver/keys/handlers/scope all stayed internal, and the two app-scoped singletons needed no visibility change at all. 6 forced, 0 over-widened, 0 stale. ENDPOINT, taken at port 13 per the rule, bracketed. Control ee95937 verified at 12 extensions against the working tree's 13. Cold calibration 28.976s. control A (1st) N=12 1.075 [1.003, 1.195] NEW (2nd) N=13 0.995 [0.965, 1.210] control B (3rd) N=12 0.988 [0.962, 1.119] within-pair drift (ctlB - ctlA) : -0.087s raw delta (new - ctlA) : -0.080s drift-corrected : -0.036s three-way ranges : OVERLAP [1.003, 1.119] THE ENDPOINT COSTS NOTHING. The thirteenth extension is -0.036s corrected — negative — and the ranges overlap, so it does not resolve. Across the entire arc no build-time reading has ever resolved outside overlapping ranges. And this bracket is the strongest evidence yet for witness 12. The within-pair drift measured here is -0.087s, nearly 2x the 0.045s yardstick, and it swamps the effect it surrounds. Under the old fixed order (new first, control second) a speed-up that size pushes new-minus-control POSITIVE; under bracketing the same comparison reads -0.036s. The SIGN of a delta in this environment is decided by measurement order whenever drift exceeds the effect — which here it does by 2x. That retroactively covers all four earlier positive deltas (+0.018 .. +0.115): every one is below the drift magnitude this bracket measured directly. Status: 13 OF 13 PORTED. No @DependencyGraph survives under feature/. StoreFactory has zero users. AppFeature has one user (app-dialogs, now ported) and may itself be deletable in the closing commits. What remains is the close-out, not ports: 1. three closing commits — delete remaining bridges + the ENUMERATED 14 orphaned accessors; AppGraph override->plain down to the StoreCoreDeps+NavigatorDeps spine; delete FeatureAssisted/StoreFactory and check AppFeature; 2. the on-device restore-cycle known-positive anchor on the base; 3. then #176. The live-workout "13/13" mislabel stays recorded as a standing correction; it was port 12, app-dialogs is 13. 5fafe04 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,12,13,13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
… are actually dead
CLOSING COMMIT 1 of the arc, now legal: the arc was indivisible until port 13, and all
13 are ported.
The instruction was to delete "the ENUMERATED 14 orphans". Enumerating them is what
stopped this from being wrong: of the 14 accessors orphaned across the five XxxDeps
deletions, only EIGHT are dead. Six are live roots and stay.
DEAD, deleted here (8):
tagRepository, resourceWrapper, exerciseRepository, setRepository,
personalRecordRepository, performedExerciseRepository,
trainingExerciseRepository, trainingRepository
LIVE, retained (6) — with their readers:
ioDispatcher PastSession/Settings/ExerciseChart/LiveWorkout identity tests
defaultDispatcher six identity tests
mainImmediateDispatcher Exercise + SingleTraining identity tests
sessionConflictResolver SingleTraining identity test
sessionRepository LiveWorkout identity test
imageStorage BaseApplication.cleanupOrphanedImageTempFiles
A count-driven deletion of "the 14 orphans" would have broken six things. That is the
enumeration rule paying off on its last and largest use, exactly as the ledger
(1,0,1,3,9) predicted it would need to.
The enumeration was PROVEN to discriminate before it was acted on: deleting one LIVE
orphan (sessionRepository) fails compilation with 1 error; deleting the 8 dead ones is
green. Not "these look unused" — measured both ways.
detekt then caught 7 imports that the deletions stranded; removed. ResourceWrapper's
import stays, still referenced elsewhere in the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…ture is assisted CLOSING COMMIT 2. StoreFactory was the assisted-store seam: every route-arg feature's nested @AssistedFactory implemented it so the graph could expose a factory instead of the Store. Shape B removed the assisted machinery from all seven route-arg features — the Screen is now a @provides bound instance on the contributed extension factory and each Store is a plain @Inject class — so the interface has no implementors left. Verified by grep across the repo before deleting: the only remaining occurrences were its own declaration and an import + two KDoc references in FeatureAssisted. Everything else matching "StoreFactory" is androidx PreferenceDataStoreFactory, unrelated. FeatureAssisted's KDoc said its processor was "backed by a Hilt assisted-factory StoreFactory<TScreen, TStoreImpl>" — false on two counts now (not Hilt, not assisted). Rewritten to describe what actually builds the Store: shape B binding the Screen on the feature's contributed @GraphExtension.Factory. Kept deliberately separate from closing commit 1 (the orphan deletions) so a later bisect can tell "the accessor deletions broke it" from "the StoreFactory deletion broke it" — the same distinguishability the arc ran on throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…efuted, anchor blocked Two closing commits landed (1f62da9, 4b569f9). Three more planned items do not survive measurement, and one is blocked on credentials. ORPHAN DELETION, enumerated not counted: of the 14 orphans only EIGHT are dead. Six are live roots — ioDispatcher, defaultDispatcher, mainImmediateDispatcher, sessionConflictResolver and sessionRepository are read by the :app identity tests, and imageStorage by BaseApplication.cleanupOrphanedImageTempFiles. Deleting "the 14" would have broken six things. Proven both ways before acting. REFUTED, each by grep rather than assumption: * FeatureAssisted CANNOT be deleted — 7 live users. Every ported route-arg feature still extends it; NavComponentScreen and ScreenInjectionRule reference it. Its difference from Feature is processor(screen) vs processor(), a COMPOSITION-time concern the DI port never touched. The plan conflated "delete the assisted DI machinery" with "delete the assisted composition base class"; only the first was ever true. * AppFeature CANNOT be deleted — app-dialogs still extends it, plus AppFeatureScopeTest and AppFeatureProbe in core:ui:mvi androidTest. * The AppGraph override->plain collapse is ALREADY DONE, distributed across the five XxxDeps deletion commits — the 14 orphan conversions WERE that collapse. There is no separate commit to write, and the 11 accessors still carrying `override` all override a live supertype. * The supertype list bottoms out at FOUR, not two. StoreCoreDeps + NavigatorDeps are the feature-facing spine, but RecoveryDeps and BackupWorkerDeps are live FRAMEWORK seams: RecoveryActivity reads RecoveryDeps via RecoveryDepsHolder, MetroWorkerFactory reads BackupWorkerDeps via BackupWorkerDepsHolder — entry points that must not depend on core:ui:mvi. Collapsing "to the spine" as written would break both. BLOCKED: the on-device restore-cycle anchor cannot be run by an agent. tech-debt.md:219 records it as the manual/device restore-gate baseline, explicitly NOT an automated test, and BackupStorage is DriveBackupStorage — restore requires a signed-in Google account, live Drive OAuth, and an existing backup. An emulator is attached and the base cf328bf is reachable, so it can be run on that fixed SHA whenever; the ordering is not lost by the two closing commits, since nothing is merged. It must be run before merge. The existing metro-batch-anchor tag (64f875d) belongs to the App-Scope Collapse arc, not this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
The plan and the reality disagreed in six places. This records what `git diff cf328bf..HEAD` and grep actually show, and marks the plan wrong where it was. THE ARC'S OWN HEADLINE was wrong in three of five numbers: @provides bound instances 125 -> 0 EXACT *BridgeTest.kt 13 -> 0 EXACT XxxDeps interfaces 11 of 15 4 are load-bearing override val accessors 30 -> 11 11 override a live supertype the `as T` cast seam STILL PRESENT every port depends on it The `as T` line was self-contradictory from the start: shape B's flip point IS context.appDeps<XxxGraph.Factory>(), which goes through that cast. The seam was GENERALIZED, not deleted — which the body of this document said all along under "load-bearing, stays". Headline and body disagreed for the whole arc; the body was right. THE SUPERTYPE FLOOR was wrong twice, and climbed both times: assumed 0, measured 2 (gamma-spine is not transient), measured 4 at close-out — RecoveryDeps and BackupWorkerDeps are live framework seams read by RecoveryActivity and MetroWorkerFactory via typed holders, neither of which may depend on core:ui:mvi. Recorded against STANDING RULE 5 witness 3, which is now a two-time miss on the same number. THE APPGRAPH COLLAPSE never could have been one commit. The override->plain conversion was distributed across the five XxxDeps deletions — the 14 orphan conversions WERE the collapse. AppGraph went 43 accessors / 30 override at the base to 35 / 11 now, incrementally. The "one big final collapse" model was wrong for the entire arc; only measurement showed the file had been changing a little at a time. FEATUREASSISTED AND APPFEATURE STAY. FeatureAssisted has 7 live users and its processor(screen) vs processor() difference is a COMPOSITION-time concern the DI port never touched; AppFeature is still app-dialogs' base plus its own androidTest probe. The plan conflated deleting the assisted DI machinery (StoreFactory — done) with deleting the assisted composition base class (never possible). ENUMERATION, final tally: 8 of 14 orphans dead, 6 live roots retained with their readers named. STILL OUTSTANDING, and it is the only thing: the on-device restore-cycle known-positive anchor on cf328bf. Manual, credential-requiring, not runnable by an agent. The doc now carries an explicit do-not-substitute warning — something-green named "known-positive" would answer "something passed" instead of "restore works", and both read as a tick — plus the trap that metro-batch-anchor (64f875d) is the App-Scope Collapse arc's anchor, not this one's. Nothing is merged, so the ordering is intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6977fbcb7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
stslex
left a comment
There was a problem hiding this comment.
Reviewed the full merge diff at c6977fb. I found two additional false-green paths in the new DI guardrails and called them out inline. I did not duplicate the three existing unresolved findings.
stslex
left a comment
There was a problem hiding this comment.
Review — Hilt → Metro migration
Reviewed the full origin/dev...HEAD range (169 commits, 621 files) across ten areas: app-scope graph and Application lifecycle, the 13 feature @GraphExtension ports, core:ui:mvi store resolution, Room/DB, backup + WorkManager + recovery, the custom Detekt rules, build/toolchain config, the core modules, test integrity, and cross-cutting concurrency. Findings below were filed by area reviewers and then independently re-checked against the code before posting; several proposed findings were dropped as refuted or pre-existing.
The DI port itself holds up. The things most likely to be wrong here are not: binding parity is exact (every removed @Binds @ViewModelScoped has a counterpart, nothing dropped, re-scoped, or re-qualified); no feature-scoped node was silently promoted to app scope and no app singleton demoted; viewModel<TStoreImpl>() cannot key-collide (no two features share a Store type) and the initializer runs at most once per retained Store; no Activity leaks through LocalContext into an extension; MetroWorkerFactory's null-return fallback is correct against the pinned work-runtime 2.11.2; there is no @IO→appGraph back-edge; and the 14 *ExtensionIdentityTest files build the real compiled AppGraph and assert genuine extension-vs-parent inheritance, not fakes. The concurrency surface is unchanged in shape — no new GlobalScope, no new runBlocking on a startup path.
What I'd fix before merge
Two of these mean a guard you believe is running is not:
ContributesBindingScopeRulenever runs — registered in the rule set, 138 lines of green tests, noactive:key indetekt.yml. Detekt treats an unlisted rule as inactive, and the test suite can't catch it becauseConfig.emptyreturnstrueforactive. (lint-rules/detekt.yml:663)core:core's 17 unit tests are dark — the KMP move puts them underandroidHostTest, whichtestDebugUnitTest(CI's only unit-test command) does not reach.asyncAssociatewas rewritten in this PR with its suite never executed. (core/core/build.gradle.kts:19)MetroScopeRuleexempts 31 classes —hasInjectreads only the primary constructor, and this migration moved nearly everything to class-level@Inject.HiltScopeRuledid see those classes onorigin/dev. (MetroScopeRule.kt:59)- The Room 3 device tests can never be selected —
Room3RoundTripDeviceTest,AtomicRollbackDeviceTest,InvalidationDeviceTestandRecoveryActivityDbFreeTestcarry no@Smoke/@Regression, and bothui_tests.ymljobs filter by annotation. The two riskiest parts of the PR have zero reachable device coverage. AtomicRollbackDeviceTestpasses if the transaction never runs —runCatchingswallows everything and the post-condition can't distinguish "rolled back" from "never written". Assert onRollbackTrigger.RecoveryActivityDbFreeTestlost its teeth in the move — Hilt field injection used to force both collaborators atonCreate; they are nowby lazy+get()and nothing in CREATED→RESUMED reads them.- The
@SingleIn(XScope)sharing invariant is untested across all 13 features — it is the only thing making the Store's emitter and the handlers' emitter one object, and dropping it compiles, passes detekt, and passes the identity tests while crashing the screen on open.
Two things that aren't inline-anchorable
app/app/src/main/AndroidManifest.xml:53-61(unchanged by this PR, so no diff line to attach to). The comment justifyingtools:node="remove"onWorkManagerInitializerstill says the initializer must go because on-demand init installsHiltWorkerFactory, and that restoring it would break@AssistedInjectconstruction ofBackupWorker.BaseApplication.kt:94-97now installsMetroWorkerFactory(this)andBackupWorkeris a plainCoroutineWorkerwith an 8-arg constructor. The directive is still load-bearing and nothing guards it —RemoveWorkManagerInitializeris disabled inLintConventionPlugin.kt:50-62. A post-migration Hilt sweep that reads this comment and deletes the block gives you a WorkManager initialized byandroidx.startupwith a defaultConfiguration, noMetroWorkerFactory, and a reflection fallback that cannot constructBackupWorker— every scheduled backup fails, with no foreground signal.AsyncAssociateTest.kt:38-55(pure rename, no content diff). You rewroteasyncAssociatetoasyncMap { … }.toMap()and upgraded its KDoc to promise deterministic last-write-wins, but the test still documents the deletedConcurrentHashMapbehaviour ("order is not guaranteed across runs") and asserts onlyresult["a"] in setOf(1, 2, 4). The answer is now always4. Tighten toassertEquals(4, result["a"])so the guarantee you just documented is actually pinned.
Process
- The PR description is describing a different PR. Title and body still say "Step 5", Metro standing up "alongside
@HiltAndroidAppunder a reversible dual-path", "No@HiltAndroidApp/@AndroidEntryPointremoved — that is Step 6", and enumerate a residual Hilt surface (~41 adopt-back shims, 14*HiltEntryPointbridges, 2@AndroidEntryPointActivities,@HiltWorker BackupWorker) as not in this PR. None of that is true atc6977fbc: Hilt and Dagger are gone from every classpath, all 13 features are on contributed@GraphExtensions,StoreFactoryis deleted, andMetroWorkerFactoryis the liveWorkerFactory. The body also still says DO NOT MERGE. A reviewer who reads it will believe this change is reversible and that the irreversible cut is a future PR — and may approve without the on-device backup/restore baseline, which is now the only gate on an irreversible Hilt-free startup path. - The branch bundles four independent changes. Beyond the DI cut: Room 2.8.4 → room3 3.0.0 (new
setDriver(AndroidSQLiteDriver()), newAppDatabaseFactory), thecore:core→core:core+core:core-androidKMP split, and a full toolchain bump (Kotlin 2.3.20→2.4.10, KSP 2.3.6→2.3.9, AGP 9.1.0→9.3.0, compileSdk 36→37, Gradle wrapper 9.5.0→9.6.1 with the jar replaced, JUnit 5→6,vkomposeremoved). Each has its own failure mode, and none can be bisected independently because the same commits that touch Room also delete Hilt. The KMP split already caused finding #2 above, and it is invisible when the diff is read as a DI change. If splitting isn't practical now, please at least state the bundle in the description and say which gate covers each risk. :lint-rulestests never run in CI (pre-existing, not introduced here — flagging because this PR adds five new rule test classes into that module). It's akotlin("jvm")module, so its task istest, nottestDebugUnitTest; Gradle matches by name and silently skips it.detektPlugins(project(":lint-rules"))builds the jar without compiling the test source set. Adding:lint-rules:testto the CI step would have caught finding #1 and #3 as red builds.
Nothing in this review was applied to the code — comments only, as requested.
PR #176 review — threads on MetroScopeRule, ScreenInjectionRule, ContributesBindingScopeRule, ScopeClassType and detekt.yml. - MetroScopeRule only read `klass.primaryConstructor?.annotationEntries`, so every class carrying a CLASS-level `@Inject` — the shape this arc moved almost everything to — returned before the scope check. 31 name-matched classes were invisible to the gate (13 *HandlerStoreImpl, 12 *InteractorImpl, StateStatusMapper, CommonDataStoreImpl, AccountDataStoreImpl, BackupPreferencesRepositoryImpl, RestoreStateRepositoryImpl, StoreDispatchers). Injection detection is now `isMetroInjected()` — class OR primary ctor — and the intentionally-unscoped Store carve-out is explicit by name instead of riding on that short-circuit. All 31 already declare `@SingleIn`, so the widened gate is clean on this tree. - ScreenInjectionRule exempted `*StoreImpl` by suffix, which also matched every `*HandlerStoreImpl` adapter — those are event emitters, not BaseStore impls, so a `Screen.X` ctor dep on one bypassed the rule. Exemption narrowed via the shared `ScopedClassNames.isStoreImpl`. - ContributesBindingScopeRule validated only the FIRST `@ContributesBinding`; the annotation is repeatable and two live classes carry two entries each (ActivityHolderImpl, DatabaseSnapshotProviderImpl). It now checks every entry and anchors each finding at its own annotation. - ContributesBindingScopeRule was registered in the provider and shipped with tests, but detekt.yml had no key for it — an absent entry means the rule does not run, so the gate was dead while its `Config.empty` unit tests stayed green by construction. Activated. - ScopeClassType's SINGLETON/FEATURE_SCOPED split was write-only (the sole consumer discarded the value). Collapsed into `ScopedClassNames`. - Dropped the `dagger.hilt.EntryPoint` TooManyFunctions exemption and its "Hilt code is unaffected" clause; no Hilt remains on any classpath. Every behaviour change carries a positive/negative test pair, and all three rules were proven to fire against the real tree under the production config (mis-scoped a live @ContributesBinding, dropped a @singlein, and gave a *HandlerStoreImpl a Screen dep — each turned `./gradlew detekt` red). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — AppGraph accessor / StoreCoreDeps / AppDialogPublisherHolder threads. - Seven AppGraph accessors had zero readers repo-wide (numUiUtils, activityHolder, platformInfoProvider, tempFileProvider, restoreStateRepository, accountDataStore, statsRepository) while their KDoc named readers that do not exist ("the seam test uses it...", "exposes the binding for identity tests"). Deleted with their imports. Re-measured per accessor, including app/app's test and androidTest source sets, rather than inherited from the review. - StoreCoreDeps and NavigatorDeps were ADDED by this PR and ended it with zero readers: nothing calls `appDeps<StoreCoreDeps>()` or `appDeps<NavigatorDeps>()`, because every feature now inherits analytics/logger/dispatchers/navigator from the parent graph through its @GraphExtension. This PR deleted all 13 analogous XxxDeps bridges for exactly that reason. Deleted both, dropped the supertypes and the `override`s they forced. - AppDialogPublisherHolder + Context.appDialogPublisher() were likewise added dead. The consumer their KDoc named (feature/settings) takes AppDialogPublisher as a constructor dep from the graph instead. The sibling AppDialogInternalsHolder was deleted for the same reason in a750d71. Leaving it would invite the next dialog producer to reintroduce the Application-cast seam this arc spent 13 ports removing. - ArchiveGraph gains two accessors so the @singlein(FeatureScope) sharing invariant is testable — see the test commit. The four accessors that survive with no reader are now documented as compile-time binding-resolution assertions rather than attributed to readers that do not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — the "passes vacuously" / "no CI job can select this" threads. Reachability: - The five device tests under core/data/database/src/androidTest carried no suite annotation, and BOTH instrumented CI jobs filter by annotation, so no job could ever select them — the Room 2->3 evidence was unreachable. Annotated @regression and added the androidTestImplementation edge on :core:ui:test-utils that supplies the annotation (it genuinely was not on that classpath; the same androidTest-out/main-back shape already exists in core/ui/mvi). - RecoveryActivityDbFreeTest was likewise unannotated. Vacuity: - AtomicRollbackDeviceTest: all four tests were `runCatching { ...; throw }` followed by "table is empty". runCatching swallows everything, and an empty table is satisfied both by rollback-after-write AND by the writes never happening. The file's whole value is being a trustworthy oracle (its own KDoc records two false negatives under Robolectric). Now asserts the caught throwable is the named RollbackTrigger, and reads the rows back INSIDE the transaction before the throw — turning "empty after" into "written, then rolled back". - RecoveryActivityDbFreeTest: post-migration RecoveryActivity resolves its collaborators lazily and onCreate only binds callable references, so walking to RESUMED no longer forced either one out of the graph — the tripwire could not fire through them. Added @VisibleForTesting warmDeps() and call it inside the tripwire window; KDoc corrected. - AppGraphIdentityTest: `separate graphs own separate instances` asserted assertNotNull on two non-nullable reads, so it could only fail if buildGraph() threw, which test 1 already covers. Now assertNotSame. - AppFeatureScopeTest: the assertSame identity claim had been downgraded to assertNotNull on a platform-non-null API. Restored by capturing the Store via viewModel() in the same setContent block. - ExerciseFormBasicsTest: the method name promised three checks and the body did one and a half. State is now hoisted so the empty->non-empty transition really drives the button, and Save is tapped. New coverage: - Nothing tested the @singlein(FeatureScope) invariant that replaced ~60 @ViewModelScoped annotations, even though it is the only thing making the Store's emitter and the handlers' emitter one object. Two tests added with BOTH assertSame operands inside one extension. Verified they fail for the right reason: deleting @singlein from ArchiveHandlerStoreImpl fails exactly these two and leaves the pre-existing two passing. - Deleted QualifiedBridgeCollisionTest: it declared its own @DependencyGraph and asserted Metro's own behaviour with no production type participating, and still passed with includeJavax() removed from the modules it claimed to guard. ExerciseExtensionIdentityTest / PastSessionExtensionIdentityTest already assert the qualified pair against the real AppGraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
…dges PR #176 review — core/core/build.gradle.kts and core/ui/kit/build.gradle.kts threads. - core:core moved to convention.kmpLibrary and its tests to src/androidHostTest, but the AGP KMP android target has no build types: it names the host-test task after the component identity (testAndroidHostTest), so `testDebugUnitTest` — the ONE unit-test command CI runs — did not exist here, and Gradle silently skips projects lacking a named task. 17 tests (AsyncAssociateTest 8, NumUiUtilsTest 9) never ran, including the suite for the asyncAssociate rewrite in this same PR. Added a name-independent alias that depends on the live tasks.withType<Test>() collection. Verified by artifact, not by exit code: :core:core:testDebugUnitTest now drives testAndroidHostTest and emits test-results XML with 9 and 8 tests, matching CI's publish globs. - Added src/androidHostTest/kotlin to core:core's detekt source set — it was outside the gate. That required teaching lint-rules/detekt.yml about the glob (previous commit): androidHostTest matches none of detekt's default test excludes, so MagicNumber and FunctionNaming would have gone red on entry. - 11 modules carried an `implementation(project(":core:core-android"))` edge justified by a comment claiming the AppScope token lives there. It does not — 2f9c89d (this branch) moved it to core:core commonMain precisely so a module could see it without an Android-only edge. Every module was checked against the actual public surface of core:core-android; all used only symbols declared in core:core, and all keep a direct :core:core edge. Edges and comments removed. - Swept the build scripts and convention plugins of comments describing a Hilt/dual-path world that no longer exists ("the convention still force-applies the Hilt plugin", "@modules aggregate into the app's single Dagger graph", "coexists with the module's Hilt @module", names like TestAppGraphModule that never existed). grep -ri hilt was the fastest way to get a wrong mental model of this codebase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — the doc-drift threads (AppDatabaseFactory, ImageStorageFactory, ArchiveScope, CommonExt, AppScope spec, AppGraph header). The dangerous ones first: - AppDatabaseFactory and ImageStorageFactory both said "STAGED, not the live feed" and named a Hilt module as "still the prod construction". Both Hilt modules are deleted by this PR and BaseApplication calls both factories directly — these ARE the production construction sites. AppDatabaseFactory is the one place addMigrations is applied, so someone who believes "not yet wired" and adds fallbackToDestructiveMigration to make a schema change compile wipes every user's workout database instead of routing to Scenario 2 recovery. The deliberate absence of that call is now stated as load-bearing. - .claude/skills/add-database-migration.md still told a migration author to register their Migration in CoreDatabaseModule, a file this PR deletes. That is the literal instruction a future author follows; an unregistered migration ships a crash-on-upgrade. Repointed at the MIGRATIONS array in MigrationsRegistry.kt, with the Room 3 suspend migrate(SQLiteConnection) shape. Same fix in MigrationsRegistry.kt's own KDoc, CLAUDE.md, CONTRIBUTING.md, documentation/architecture.md and documentation/testing.md. - ArchiveScope's KDoc claimed "every Metro-CONSTRUCTED archive node is @singlein(ArchiveScope::class), so one ArchiveGraph == one retained ArchiveStoreImpl". ArchiveStoreImpl says the opposite and is right — it is deliberately unscoped and retained by the ViewModelStore. Trusting the KDoc and reading graph.archiveStore twice yields two Stores sharing one handler store, and the second setStore silently rebinds the emitter. Reworded, and the stale "proven on Metro 1.1.1" pin corrected to the shipped version. - CommonExt.isTraceExecutionEnabled documented a bootstrap contract that did not exist ("set from BuildConfig.DEBUG in BaseApplication.onCreate"), leaving traceExecutionTime an unconditional pass-through in debug where origin/dev genuinely traced. Wired the assignment and corrected the KDoc. - The AppScope execution spec asserted AppScope lives in core:core-android and called commonMain a platform-axis leak, contradicting 2f9c89d on this branch. Corrected to code truth, with re-measured snapshot figures. The invariant that actually matters — every @ContributesBinding/@ContributesTo(AppScope) site lives in an Android source set — is restated and still holds (0 in commonMain/iosMain). - AppGraph's header still described a cleanup as pending that had already landed, its resourceWrapper KDoc dangled after the accessor was deleted (keeping a dead import alive, invisible to UnusedImports because KDoc links count as usage), and an ORPHANED comment sat above the wrong accessor. Also swept: the four dispatcher qualifiers said Hilt reads their @javax.inject.Qualifier (the reader is Metro's includeJavax interop), and KmpLibraryConventionPlugin explained the whole KMP/Android module split by a Hilt constraint that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
Follow-up to df0a893, which put core:core's src/androidHostTest under the detekt gate. Rules with an explicit `excludes` list were updated then; rules with NO local `excludes` silently inherit detekt's defaults, and those defaults predate AGP's KMP host-test source set — so such a rule judges host tests as production code. Audited all 16 `excludes:` keys in detekt 1.23.8's bundled default-detekt-config.yml (extracted from the jar, not recalled) against this config. Exactly one rule was in the gap: TooGenericExceptionCaught — active, no local excludes, and detekt's own default excludes test code. Its list is now spelled out locally, reproducing the default 8 globs verbatim plus '**/androidHostTest/**'. The other 15 are accounted for and unchanged: 8 are active:false here, 5 were already fixed in df0a893, 1 is absent and off by default, and MissingPackageDeclaration's default excludes are '**/*.kts' only. The `formatting:` block is deliberately untouched — detekt-formatting ships no excludes at all, because ktlint rules are meant to apply to test sources. Proven two-sided, since an exclude can only remove findings and green alone proves nothing: added a `catch (e: Exception)` under androidHostTest and ran `:core:core:detekt` — green with the glob, RED on that exact line with the glob removed. So the detector does reach these files and the exclude is load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
7f48093 removed seven AppGraph accessors that had zero readers. Three of them were the last reference to their type, so those types are now unreachable. Deleted, each verified by grepping every source set (main/test/androidTest/ commonMain/androidHostTest) plus '*.xml' and the manifest, with a positive control run first so an empty result is evidence rather than a broken command: - core:ui:kit's `NumUiUtils` interface and `NumUiUtilsImpl`. Note this is NOT the live `NumUiUtils` object in core:core commonMain, which stays — it has 9 passing tests and was one of the 17 CI could not reach before df0a893. The kit-level pair only delegated to it and had no consumer of its own. - The `ActivityHolder` interface, plus its `binding = binding<ActivityHolder>()` contribution on ActivityHolderImpl. The sibling `ActivityHolderProducer` supertype is live (MainActivity reads it) and its contribution is untouched, so ActivityHolderImpl keeps exactly one binding. Deliberately NOT swept: StatsRepository / StatsRepositoryImpl in core:data:exercise. It also has zero production consumers, but unlike the above it has a live passing DB suite (StatsRepositoryImplDbTest) and deleting it would orphan a SessionDao query chain in a third module — that reads as staged work, not rot, so it is a maintainer's call rather than a cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
…aims Three groups, all found while working the review round rather than raised in it. 1. .claude/skills/add-feature.md was entirely Hilt-era — ~20 hits for @hiltviewmodel / @Installin / @ViewModelScoped / @module / javax.inject. Following it produced a module that neither compiled nor wired into the app graph. Rewritten against the real current shape, derived by reading feature/archive end to end (scope token, contributed @GraphExtension.Factory, @BINDS, the Feature composition seam) plus feature/settings and feature/exercise for a route arg. It now states the scoping invariant explicitly, because getting it wrong is silent: the Store carries class-level @Inject and NEVER @singlein (retained by the ViewModelStore), while every *HandlerStoreImpl MUST carry @singlein(<X>Scope::class) — otherwise Metro builds one emitter for the concrete key and another for the @BINDS interface key, it compiles green, and the screen crashes on first action. The skill also now warns about the custom detekt rules a new feature will trip. 2. documentation/lint-rules.md: dropped the claim that the Kotlin version is pinned "for Hilt compatibility" and the bullet about Hilt's own lint rules shipping with the Hilt Gradle Plugin. Both false with Hilt off every classpath. app-dialogs.md spelled the scope token @singlein(AppDialogScope) where the code says AppDialogsScope. 3. The graph-extension-arc and appgraphcontract-split documents still described seams this branch deleted — most actionably HANDOFF.md's "AppDialogPublisherHolder + Context.appDialogPublisher() in the api module STAYS", an instruction a future reader would follow. These are ARC HISTORY, so the narrative is NOT rewritten: each stale statement is preserved and superseded by a dated correction note in the house style those files already use, recording what was deleted, by which commit, and what replaced it. Also fixed documentation/design-system.md's `LocalAppDimension.current // already exists` — it never existed; AppDimension is a plain object read directly. The neighbouring AppSwipeAction entry is NOT stale and was left alone: that document is a specification and implementation plan, so a component it lists as unbuilt is planned work, not drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
App-Scope Collapse — Steps 3–5 (Hilt → Metro): app scope is now fully Metro-owned
The final DI migration: every app-scope binding now resolves from a Metro
@DependencyGraph(AppScope)(AppGraph) held byBaseApplication, standing up alongside@HiltAndroidAppunder a reversible dual-path (adopt-back shims). No@HiltAndroidApp/@AndroidEntryPointremoved — that is Step 6 (the single irreversible cut), not this PR.What landed (this branch:
589777d9..bbab610f)Step 3 (context, earlier commits): the ~40-binding bulk — dispatchers, ResourceWrapper, Navigator, the google-drive auth chain (HOME-A GMS/ktor containment), the 9 exercise repos, app-dialogs, RestoreStateRepository, ActivityHolder, etc. — flipped to
@ContributesBinding(AppScope)/ provides-factory@BindingContainers, each with a single-owner adopt-back shim for its still-Hilt readers.CommonDataStore — DONE (
589777d9): migrated via Metro-native assisted injection (dagger.assisted.*→dev.zacsweers.metro.*,generateAssistedFactoriesoff). Proves the assisted→Metro mechanic exists — no Metro bump, no Kotlin-2.4.0, no Detekt-rule cascade.core/data/dataStoreruns Metro alongside Hilt-KSP with no collision.5a (
f23fe897) — DB-cascade collapsed to the AppDatabase root:AppGraph.create()shrinks 12 params → 3 roots (applicationContext,appDatabase,imageStorage).AppDatabasebecomes the explicitcreate()bound-instance test-override root (Room; the seam swaps an in-memory DB) and is not@ContributesBinding-flipped. Everything derived moves into Metro: the 9 DAOs +DbTransitionRunner→ aDbCascadeBindingContainer(@BindingContainer @ContributesTo(AppScope));DatabaseSnapshotProvider/LiveDatabaseLocator(repeatable@ContributesBindingon one impl) +DatabaseJsonExporter→@ContributesBinding. DeletedCoreDatabaseBindingsModule;CoreDatabaseModulekeeps only theAppDatabaseroot.5b (
bbab610f) — SnapshotExportRunner Metro-owned (last binding flip):@ContributesBinding(AppScope)on the now-publicSnapshotExportRunnerImpl; deletedAuthBindingsModule(its last binding); retired the two transient shims (databaseJsonExporter,snapshotStorage) whose sole still-Hilt reader it was.Invariants held
create()roots (test-override roots): an androidTest fake expressed as a@ContributesBindingnever merges into the main-compiled graph → a contribution flip would silently fall back to the real I/O impl (a false-green). Tests inject fake/in-memory viacreate().@IO→appGraphback-edge (the C2 StackOverflow class): every graph-internal@Providesreads acreate()root and/or the directDispatchers.IO; none routes through anappGraphaccessor. Proven structurally, not "it compiled".===seam tests:AppDatabaseresolves as ONE instance acrossDatabaseSnapshotProvider/LiveDatabaseLocator; every Hilt adopt-back===the Metro-owned instance).Gates (per commit, executed — not cached)
:app:dev:assembleDebug·:app:app:compileDebugAndroidTestKotlin· affected-module compile + unit tests ·detekt --no-daemon --rerun-tasks(customContributesBindingScopeRule/ContributesToScopeRule, zero suppressions). On-device Regression + the baseline restore/backup pass is deliberately left for the maintainer's review — it is the gate for merge, not a per-commit blocker.Residual Hilt surface = the Step-6 (big-bang cut) target — NOT in this PR
~41 adopt-back shims (
AppGraphAdoptBackModule); theAppDatabase+ImageStorageroot@Provides;CoreDataStoreModule/CoreUiKitModule/RecoveryBootstrapModule; 14*HiltEntryPointfeature bridges; 2@AndroidEntryPointActivities;@HiltAndroidApp(Dev/StoreMobileApp);@HiltWorker BackupWorker(+ dormantMetroWorkerFactory). Step 6 removes these atomically (mapped in Phase 0). It is separate, irreversible, and gated on the device baseline-restore pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01GJFepRMk2665d9Hb9bjR1D