diff --git a/docs/superpowers/evidence/focus-owner-focus-policy-split-2026-08-17.json b/docs/superpowers/evidence/focus-owner-focus-policy-split-2026-08-17.json new file mode 100644 index 00000000..9c941007 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-focus-policy-split-2026-08-17.json @@ -0,0 +1,50 @@ +{ + "intent": "Bring focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt (277 lines) under the repo-wide 250-line cap with no change to how a policy is parsed or applied.", + "scope": [ + "focus_owner/.../FocusPolicy.kt — reduced to the FocusPolicy data class, its predicates and its companion-object loader", + "focus_owner/.../PolicyTypes.kt — new: SUPPORTED_SCHEMA_VERSION, PolicyFormatException, CurfewWindow, HomeLocation", + "Non-goal: any change to parsing, to the curfew wrap-around rule, or to the allowlist predicates", + "Non-goal: DevicePolicyBridge.kt and EnforcementRunner.kt, which measurement rules out (see risks)" + ], + "changes": [ + "Moved the four top-level declarations the policy is built from — the schema-version constant, the exception it raises, the CurfewWindow with its midnight-wrapping contains(), and the HomeLocation with its hasCoordinates guard — into PolicyTypes.kt.", + "No call site changed and no import was added: both files are package com.kuhy.focus_owner, and Kotlin resolves top-level declarations across files in the same package. This is the same seam that worked for EnforcementDecision.kt earlier in this series.", + "Deliberately did NOT extract the companion-object loader (load/loadFile/parse), even though it is the larger block. Callers write FocusPolicy.parse(...) at eight sites including four in the tests; moving it to a top-level function would have rewritten all of them, turning a zero-risk move into a real refactor." + ], + "verification": [ + { + "command": "JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew test --rerun-tasks", + "result": "pass", + "evidence": "BUILD SUCCESSFUL, and per-class counts unchanged: EnforcementDecisionCurfewTest 13, EnforcementDecisionTest 12, EnforcementLogTest 9, EnforcementRunnerBestTest 6 — 40 tests, 0 failures. --rerun-tasks again required, since a plain gradlew test reports UP-TO-DATE." + }, + { + "command": "flutter test && flutter analyze", + "result": "pass", + "evidence": "94 tests passed, no analyzer issues." + }, + { + "command": "pre-commit run --files on both files", + "result": "pass", + "evidence": "All configured hooks pass. Zero suppressions." + }, + { + "command": "wc -l on both files", + "result": "pass", + "evidence": "FocusPolicy.kt 238, PolicyTypes.kt 48 — both under the 250-line cap, down from a single 277-line file." + }, + { + "command": "Call-site check for the loader before choosing the seam", + "result": "pass", + "evidence": "grep found FocusPolicy.parse at four test sites and FocusPolicy.load at three production sites. This corrected an earlier reading: a declaration-name scan had counted `parse` as untested, but it is exercised by every fixture in EnforcementFixtures.kt. The proxy undercounts transitive exercise, which is why the seam question rather than the file-level percentage decided this split." + } + ], + "risks": [ + "FocusPolicy.kt at 238 lines has only 12 lines of headroom. Adding two or three more policy fields would put it back over the cap, at which point the loader extraction — and its eight call-site rewrites — becomes the next move.", + "The remaining two over-cap Kotlin files are not split. DevicePolicyBridge.kt (415) references one tested declaration out of 28: it wraps Android's DevicePolicyManager, which JVM unit tests cannot instantiate, so any split there moves code no test executes. EnforcementRunner.kt (564) is exercised by six tests focused on one selection function; its location-acquisition and alarm paths need an instrumented device.", + "focus_owner has no CI workflow and no Kotlin linter (no jacoco, ktlint or detekt), so the gradle and Flutter suites are the entire verifier for this change." + ], + "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." + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt index dd9aaafa..8ff4dbc7 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt @@ -4,45 +4,6 @@ import android.content.Context import org.json.JSONObject import java.io.File -/** Schema version this build understands. */ -const val SUPPORTED_SCHEMA_VERSION = 1 - -/** Thrown when the bundled policy cannot be trusted. */ -class PolicyFormatException(message: String) : Exception(message) - -/** A nightly window that may wrap midnight (23:00 -> 05:00). */ -data class CurfewWindow(val startMinutes: Int, val endMinutes: Int) { - /** - * Whether [minutesSinceMidnight] falls inside the window. - * - * Wrapping windows are the normal case, so "inside" means at or after the - * start *or* before the end, not both. - */ - fun contains(minutesSinceMidnight: Int): Boolean = - if (startMinutes <= endMinutes) { - minutesSinceMidnight in startMinutes until endMinutes - } else { - minutesSinceMidnight >= startMinutes || minutesSinceMidnight < endMinutes - } -} - -/** The home anchor. Coordinates are null in a redacted policy. */ -data class HomeLocation( - val latitude: Double?, - val longitude: Double?, - val radiusM: Double, - val hysteresisM: Double, -) { - /** - * Whether this policy carries real coordinates. - * - * The committed asset is redacted, so enforcement must check this rather - * than treating a missing coordinate as (0, 0) — that would put "home" in - * the Atlantic and silently disable the gate. - */ - val hasCoordinates: Boolean get() = latitude != null && longitude != null -} - /** * The device-independent focus policy, generated from `config.sh`. * diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt new file mode 100644 index 00000000..dd15577b --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt @@ -0,0 +1,48 @@ +package com.kuhy.focus_owner + +/** + * The policy's value types: the schema version this build understands, the + * failure it raises, and the two records [FocusPolicy] is built from. + * + * Same package as FocusPolicy, so nothing imports these explicitly and no call + * site changes by their moving here. + */ + +/** Schema version this build understands. */ +const val SUPPORTED_SCHEMA_VERSION = 1 + +/** Thrown when the bundled policy cannot be trusted. */ +class PolicyFormatException(message: String) : Exception(message) + +/** A nightly window that may wrap midnight (23:00 -> 05:00). */ +data class CurfewWindow(val startMinutes: Int, val endMinutes: Int) { + /** + * Whether [minutesSinceMidnight] falls inside the window. + * + * Wrapping windows are the normal case, so "inside" means at or after the + * start *or* before the end, not both. + */ + fun contains(minutesSinceMidnight: Int): Boolean = + if (startMinutes <= endMinutes) { + minutesSinceMidnight in startMinutes until endMinutes + } else { + minutesSinceMidnight >= startMinutes || minutesSinceMidnight < endMinutes + } +} + +/** The home anchor. Coordinates are null in a redacted policy. */ +data class HomeLocation( + val latitude: Double?, + val longitude: Double?, + val radiusM: Double, + val hysteresisM: Double, +) { + /** + * Whether this policy carries real coordinates. + * + * The committed asset is redacted, so enforcement must check this rather + * than treating a missing coordinate as (0, 0) — that would put "home" in + * the Atlantic and silently disable the gate. + */ + val hasCoordinates: Boolean get() = latitude != null && longitude != null +}