focus_owner: split EnforcementDecision.kt 274 -> 221 + types

Moved the four value types the decision is expressed in -- HideReason,
Geofence, EnforcementReason and EnforcementInputs -- to EnforcementTypes.kt
(63). No import anywhere changed: same package, and Kotlin resolves those
across files, so no consumer and no test needed touching.

Picked this file by measuring, not by line count. Comparing every declaration
against the text of the test sources gives EnforcementDecision 66%,
FocusPolicy 35%, EnforcementRunner 31%, DevicePolicyBridge 3%. Only this one
has a test suite strong enough to verify a split; DevicePolicyBridge is an
Android DevicePolicyManager wrapper JVM tests cannot reach at all.

40 gradle tests (13+12+9+6) and 94 flutter tests still pass, analyze clean.
Note --rerun-tasks: a plain gradlew test reports UP-TO-DATE and proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 10:26:18 +02:00
parent 7ff048be1c
commit 4dcc0beee1
3 changed files with 120 additions and 55 deletions

View File

@ -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 <changed 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."
]
}

View File

@ -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<String>,
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

View File

@ -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<String>,
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,
)