fix(s54): reject non-array/non-object ledger JSON in SavingsLedger.load

S5.4 review MINOR (SC5 fail-fast hole, run.py:740). A valid-JSON but
wrong-shape savings ledger escaped the --report fail-fast refusal:
  - top-level {} iterated zero keys -> entries=[] -> rc 0 "0,00 kr"
    (a malformed file masquerading as a real zero-savings result)
  - a bare scalar / object-with-keys / list-of-non-objects raised an
    uncaught TypeError -> traceback (violates SC5 "rc 1, no traceback")

Fix at the fail-fast boundary, not the run.py except tuple: the review's
first option (add TypeError to run.py:740) leaves the {} masquerade
because {} is an empty iteration, not a TypeError. SavingsLedger.load now
raises ValueError for a non-array top-level and a non-object row, caught
by run.py:740's existing ValueError arm. Hardens both callers
(run.py:740 report + run.py:785 portfolio).

RED-first: 6 unit cases (test_ledger) + 2 CLI rc-1 cases (test_run_cli).
452 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 19:52:52 +02:00
commit 7dab2dfb78
3 changed files with 65 additions and 4 deletions

View file

@ -152,14 +152,28 @@ class SavingsLedger(BaseModel):
@classmethod
def load(cls, path: str) -> SavingsLedger:
"""Fail-fast load (mirrors ``okf.load_ir_projection``'s required-input semantics): a missing
file raises ``FileNotFoundError``; a malformed row raises ``pydantic.ValidationError``.
Contrast the tolerant verdict inbox (``load_verdicts_from_dir``), which SKIPS bad files
the ledger is authoritative input, not an out-of-band drop folder."""
file raises ``FileNotFoundError``; a non-array top-level or non-object row raises
``ValueError`` (a valid-JSON ``{}`` must NOT masquerade as an empty zero-savings ledger, and
a bare scalar / non-object row must NOT leak an uncaught ``TypeError``); a malformed object
row raises ``pydantic.ValidationError``. Contrast the tolerant verdict inbox
(``load_verdicts_from_dir``), which SKIPS bad files the ledger is authoritative input, not
an out-of-band drop folder."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"savings ledger not found: {path!r}")
rows = json.loads(p.read_text(encoding="utf-8"))
return cls(entries=[LedgerEntry(**row) for row in rows])
if not isinstance(rows, list):
raise ValueError(
f"savings ledger must be a JSON array of entries, got {type(rows).__name__}: {path!r}"
)
entries = []
for row in rows:
if not isinstance(row, dict):
raise ValueError(
f"savings ledger entry must be a JSON object, got {type(row).__name__}: {path!r}"
)
entries.append(LedgerEntry(**row))
return cls(entries=entries)
def realize(