diff --git a/loop/contract.py b/loop/contract.py index 7dc17f7..54165e7 100644 --- a/loop/contract.py +++ b/loop/contract.py @@ -56,6 +56,11 @@ def _read_json(path: Path, issues: list[dict]) -> dict[str, Any] | None: return None try: data = json.loads(path.read_text(encoding="utf-8")) + except UnicodeDecodeError as exc: + # Same rule as _validate_jsonl: undecodable bytes fail the file closed + # with a typed finding, never a traceback out of doctor_report (#107). + issues.append(ContractIssue("invalid_encoding", f"{path.name}: not valid UTF-8: {exc}", path)) + return None except json.JSONDecodeError as exc: issues.append(ContractIssue("invalid_json", f"{path.name}: {exc}", path)) return None @@ -139,7 +144,12 @@ def _fallback_yaml(text: str) -> dict[str, Any]: def read_manifest(path: Path) -> dict[str, Any] | None: if not path.exists(): return None - text = path.read_text(encoding="utf-8") + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + # Undecodable bytes are malformed content: fail safe to {} exactly like + # the malformed-YAML branch below, never propagate a traceback (#107). + return {} try: import yaml # type: ignore except Exception: diff --git a/loop/verdict.py b/loop/verdict.py index c5cb62c..7ae0912 100644 --- a/loop/verdict.py +++ b/loop/verdict.py @@ -47,7 +47,9 @@ def _terminal_record(paths: LoopPaths) -> dict[str, Any]: ) try: data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + # UnicodeDecodeError became reachable here once doctor_report stopped + # raising it (#107); the site-agnostic typed-contract test depends on it. raise VerdictError(f"terminal record is unreadable: {exc}") from exc if not isinstance(data, dict): raise VerdictError("terminal record is not an object") diff --git a/scripts/test_contract_records.py b/scripts/test_contract_records.py index 3d166e8..e4b1552 100644 --- a/scripts/test_contract_records.py +++ b/scripts/test_contract_records.py @@ -180,3 +180,28 @@ def test_repo_own_contract_still_validates_clean(): pytest.skip("no live .loop contract in this checkout (gitignored run-state)") report = validate_contract(ROOT / ".loop") assert report["ok"] is True, report["issues"] + + +def test_doctor_reports_invalid_encoding_for_an_undecodable_contract_object(tmp_path): + # #107: a terminal_state.json holding invalid UTF-8 bytes is a typed doctor + # finding, never a raw UnicodeDecodeError traceback out of doctor_report. + from loop.contract import doctor_report + from loop.scaffold import scaffold + + workspace = tmp_path / "ws" + scaffold(workspace) + (workspace / ".loop" / "terminal_state.json").write_bytes(b"\xff\xfe{}") + + report = doctor_report(workspace) + assert report["ok"] is False + assert any(i["code"] == "invalid_encoding" for i in report["issues"]), report["issues"] + + +def test_read_manifest_fails_safe_on_undecodable_bytes(tmp_path): + # Mirrors the malformed-YAML rule stated inline in read_manifest: a manifest + # that cannot be decoded fails safe to {} rather than propagating a traceback. + from loop.contract import read_manifest + + path = tmp_path / "manifest.yaml" + path.write_bytes(b"\xff\xfename: x") + assert read_manifest(path) == {}