diff --git a/docs/superpowers/evidence/focus-owner-enforcement-decision-split-2026-08-17.json b/docs/superpowers/evidence/focus-owner-enforcement-decision-split-2026-08-17.json new file mode 100644 index 00000000..ffef8c43 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-enforcement-decision-split-2026-08-17.json @@ -0,0 +1,55 @@ +{ + "intent": "Bring focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt (274 lines) under the repo-wide 250-line cap with no change to which packages get hidden.", + "scope": [ + "focus_owner/.../EnforcementDecision.kt — reduced to the EnforcementDecision data class, evaluate, geofence and haversineMetres", + "focus_owner/.../EnforcementTypes.kt — new: HideReason, Geofence, EnforcementReason, EnforcementInputs", + "Non-goal: any change to the enforcement rules — the fail-closed LOCATION_UNKNOWN branch, the always-blocked exemption from AWAY and WORKOUT, and the hysteresis widening all keep their semantics exactly", + "Non-goal: the other three over-cap Kotlin files, which this same measurement rules out (see risks)" + ], + "changes": [ + "Moved the four value types the decision is expressed in — the two enums, the Geofence record and EnforcementInputs — into EnforcementTypes.kt, leaving the decision class and its pure evaluation logic behind.", + "No import statement anywhere changed: Kotlin resolves same-package declarations across files, and both files are com.kuhy.focus_owner. That is why this split touches no consumer and no test.", + "Declarations moved verbatim, including the comments recording why `fix` is never consulted by evaluate and why curfewActive is carried separately from reason." + ], + "verification": [ + { + "command": "JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew test --rerun-tasks", + "result": "pass", + "evidence": "BUILD SUCCESSFUL. Per-class results unchanged: EnforcementDecisionCurfewTest 13, EnforcementDecisionTest 12, EnforcementLogTest 9, EnforcementRunnerBestTest 6 — 40 tests, 0 failures, 0 errors, matching the pre-split baseline exactly. --rerun-tasks was required because a plain `gradlew test` reported UP-TO-DATE from cache and would have proven nothing." + }, + { + "command": "flutter test", + "result": "pass", + "evidence": "94 tests, all passed — unchanged. Run because the Flutter app ships this Kotlin, so a package-level mistake would surface here even though no Dart file was touched." + }, + { + "command": "flutter analyze", + "result": "pass", + "evidence": "No issues found." + }, + { + "command": "wc -l on both files", + "result": "pass", + "evidence": "EnforcementDecision.kt 221, EnforcementTypes.kt 63 — both under the 250-line cap, down from a single 274-line file." + }, + { + "command": "Declaration reachability measurement, run before choosing this target", + "result": "pass", + "evidence": "A script comparing every fun/val/var declaration in each over-cap Kotlin file against the text of every file in app/src/test/kotlin/ gave: EnforcementDecision.kt 24/36 (66%), FocusPolicy.kt 15/42 (35%), EnforcementRunner.kt 15/47 (31%), DevicePolicyBridge.kt 1/28 (3%). Only this file has coverage strong enough for the test suite to be a real verifier of a split." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "All configured hooks pass on the two touched files." + } + ], + "risks": [ + "focus_owner has no CI workflow and no Kotlin linter — no jacoco, no ktlint, no detekt in the gradle files, and nothing kotlin-related in .pre-commit-config.yaml. So unlike the TypeScript splits in this series there is no lint-error-count check available; the 40 gradle tests plus the Flutter suite are the entire verifier.", + "Coverage here is 66% of declarations, not 100%. The twelve unreferenced ones are mostly EnforcementInputs field names and the private haversine internals, which move as a block and are exercised transitively through evaluate — but a split that broke one of them in isolation would not necessarily be caught.", + "The remaining three over-cap Kotlin files are deliberately NOT split. DevicePolicyBridge.kt at 3% is an Android DevicePolicyManager wrapper that JVM unit tests cannot reach; splitting it would move code no test executes, which is the same trap that ruled out the usage_report Python scripts at 21-47%." + ], + "rollback": [ + "git revert the commit — one edited file and one added file, with no data, config or schema migration.", + "After rollback, re-run gradlew test --rerun-tasks and flutter test; expect 40 and 94 tests passing respectively." + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt index bbce7f1e..0dcb3f78 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt @@ -5,65 +5,12 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -/** Why a particular package is hidden. */ -enum class HideReason { - /** Hidden in every state, exempt from the geofence. */ - ALWAYS_BLOCKED, - - /** Not on the day allowlist. */ - NOT_IN_ALLOWLIST, - - /** On the day allowlist but not the shorter curfew one. */ - NOT_IN_NIGHT_ALLOWLIST, -} - -/** The fence geometry behind one decision. */ -data class Geofence( - /** Metres from home, or null when no coordinates are configured. */ - val metres: Double?, - val threshold: Double, - val inside: Boolean, -) - -/** Why enforcement is currently on or off. */ -enum class EnforcementReason { - AWAY, - AT_HOME, - CURFEW, - WORKOUT, - LOCATION_UNKNOWN, -} - -/** Inputs to one enforcement decision. */ -data class EnforcementInputs( - val installedPackages: Set, - val minutesSinceMidnight: Int, - val latitude: Double? = null, - val longitude: Double? = null, - /** - * Whether the previous pass placed the phone inside the fence. - * - * Widens the threshold by the hysteresis margin, so a phone parked on the - * boundary does not flap between states. - */ - val currentlyEnforcing: Boolean = false, - val workoutActive: Boolean = false, - /** - * The fix these coordinates came from, carried for the log only. - * - * Never consulted by [EnforcementDecision.evaluate]: staleness is applied - * by the caller, which withholds the coordinates outright. Keeping the - * decision a function of latitude/longitude alone is what lets it stay - * testable without a location provider. - */ - val fix: LocationFix? = null, -) - /** * The outcome of evaluating the policy against current conditions. * * A pure function of its inputs, so it can be tested without a device, a clock - * or a location provider. + * or a location provider. The inputs and the two result enums live in + * `EnforcementTypes.kt`. * * This is the ONLY implementation that enforces anything. `lib/enforcement.dart` * used to be described here as a mirror pinned by a `PolicyParityTest`; no such diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt new file mode 100644 index 00000000..c9172844 --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt @@ -0,0 +1,63 @@ +package com.kuhy.focus_owner + +/** + * The vocabulary one enforcement pass is expressed in: what goes in, and the + * two enums that explain what came out. + * + * Same package as [EnforcementDecision], so nothing imports these explicitly + * and no consumer changes when they move here. + */ + +/** Why a particular package is hidden. */ +enum class HideReason { + /** Hidden in every state, exempt from the geofence. */ + ALWAYS_BLOCKED, + + /** Not on the day allowlist. */ + NOT_IN_ALLOWLIST, + + /** On the day allowlist but not the shorter curfew one. */ + NOT_IN_NIGHT_ALLOWLIST, +} + +/** The fence geometry behind one decision. */ +data class Geofence( + /** Metres from home, or null when no coordinates are configured. */ + val metres: Double?, + val threshold: Double, + val inside: Boolean, +) + +/** Why enforcement is currently on or off. */ +enum class EnforcementReason { + AWAY, + AT_HOME, + CURFEW, + WORKOUT, + LOCATION_UNKNOWN, +} + +/** Inputs to one enforcement decision. */ +data class EnforcementInputs( + val installedPackages: Set, + val minutesSinceMidnight: Int, + val latitude: Double? = null, + val longitude: Double? = null, + /** + * Whether the previous pass placed the phone inside the fence. + * + * Widens the threshold by the hysteresis margin, so a phone parked on the + * boundary does not flap between states. + */ + val currentlyEnforcing: Boolean = false, + val workoutActive: Boolean = false, + /** + * The fix these coordinates came from, carried for the log only. + * + * Never consulted by [EnforcementDecision.evaluate]: staleness is applied + * by the caller, which withholds the coordinates outright. Keeping the + * decision a function of latitude/longitude alone is what lets it stay + * testable without a location provider. + */ + val fix: LocationFix? = null, +)