mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
105 lines
3.7 KiB
Python
Executable File
105 lines
3.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Pre-commit hook: ban silent failures.
|
|
|
|
A caught exception that is swallowed (not re-raised) must say WHY in the log,
|
|
loudly. Otherwise the failure is invisible: the caller sees a bland ``None`` /
|
|
``[]`` / early return and cannot tell "nothing to do" from "it broke". That is
|
|
not hypothetical — the PC's workout sync silently did nothing for weeks because
|
|
a missing token returned ``[]`` and a real error only logged at ``INFO``.
|
|
|
|
The rule: every ``except`` handler must either
|
|
|
|
* re-``raise`` (the failure is someone else's problem), or
|
|
* log at ``warning`` / ``error`` / ``exception`` / ``critical`` with the reason.
|
|
|
|
``debug`` / ``info`` do not count — an invisible log is a silent failure with
|
|
extra steps. ruff's ``S110`` only catches ``except: pass``; this catches the
|
|
far more common ``except: log.info(...); return []``.
|
|
|
|
There is deliberately no escape hatch (per-line lint suppressions are banned
|
|
repo-wide too): if a failure is truly benign, say so at ``warning`` and move on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Log levels loud enough that a human actually sees the failure.
|
|
_LOUD_LOG_METHODS = frozenset({"warning", "error", "exception", "critical"})
|
|
|
|
|
|
def _is_loud_log_call(node: ast.AST) -> bool:
|
|
"""Return True if ``node`` is a ``<something>.warning(...)``-style call."""
|
|
return (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr in _LOUD_LOG_METHODS
|
|
)
|
|
|
|
|
|
def _handler_is_loud(handler: ast.ExceptHandler) -> bool:
|
|
"""Return True if the handler re-raises or logs loudly about the failure."""
|
|
return any(
|
|
isinstance(node, ast.Raise) or _is_loud_log_call(node)
|
|
for node in ast.walk(handler)
|
|
)
|
|
|
|
|
|
def _handler_label(handler: ast.ExceptHandler) -> str:
|
|
"""Return a readable name for what the handler catches."""
|
|
if handler.type is None:
|
|
return "except:"
|
|
return f"except {ast.unparse(handler.type)}:"
|
|
|
|
|
|
def _check_source(path: Path, source: str) -> list[str]:
|
|
"""Return one message per silently-swallowing except handler in ``source``."""
|
|
try:
|
|
tree = ast.parse(source, filename=str(path))
|
|
except SyntaxError as exc:
|
|
_logger.warning(
|
|
"Could not parse %s (%s at line %s) — reporting it as a problem "
|
|
"rather than skipping it, since its handlers cannot be checked",
|
|
path,
|
|
exc.msg,
|
|
exc.lineno,
|
|
)
|
|
return [f"{path}:{exc.lineno}: could not parse ({exc.msg})"]
|
|
return [
|
|
f"{path}:{node.lineno}: {_handler_label(node)} swallows the error without "
|
|
f"logging why — re-raise, or log at warning/error/exception/critical"
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.ExceptHandler) and not _handler_is_loud(node)
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
"""Return 1 if any file swallows an exception silently, else 0."""
|
|
problems: list[str] = []
|
|
for filename in sys.argv[1:]:
|
|
path = Path(filename)
|
|
try:
|
|
source = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
_logger.warning(
|
|
"Could not read %s (%s) — reporting it as a problem rather than "
|
|
"skipping it, since its handlers cannot be checked",
|
|
path,
|
|
exc,
|
|
)
|
|
problems.append(f"{path}: could not read ({exc})")
|
|
continue
|
|
problems.extend(_check_source(path, source))
|
|
for problem in problems:
|
|
sys.stdout.write(f"{problem}\n")
|
|
return 1 if problems else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|