diff --git a/interlocks/behavior_coverage.py b/interlocks/behavior_coverage.py index 2f9bf16..990d21b 100644 --- a/interlocks/behavior_coverage.py +++ b/interlocks/behavior_coverage.py @@ -373,6 +373,12 @@ def duplicates(self) -> tuple[DuplicateBehavior, ...]: "a subprocess gate failure exits via SystemExit without entering capture", "interlocks.crash.boundary:CrashBoundary", ), + Behavior( + "crash-malformed-config-no-capture", + "crash", + "malformed pyproject.toml raises InterlockConfigError (user error) — no crash capture", + "interlocks.config:InterlockConfigError", + ), ) diff --git a/interlocks/config.py b/interlocks/config.py index c5d67e4..d203404 100644 --- a/interlocks/config.py +++ b/interlocks/config.py @@ -469,8 +469,11 @@ def _load_pyproject(project_root: Path) -> dict[str, Any]: path = project_root / "pyproject.toml" if not path.is_file(): return {} - with path.open("rb") as f: - return tomllib.load(f) + try: + with path.open("rb") as f: + return tomllib.load(f) + except tomllib.TOMLDecodeError as exc: + raise InterlockConfigError(f"pyproject.toml is not valid TOML: {exc}") from exc def _interlock_table(pyproject: dict[str, Any]) -> dict[str, Any]: @@ -686,7 +689,7 @@ def load_optional_config(start: Path | None = None) -> InterlockConfig | None: """ try: return load_config(start) - except (OSError, tomllib.TOMLDecodeError): + except (OSError, InterlockConfigError): return None diff --git a/interlocks/tasks/doctor.py b/interlocks/tasks/doctor.py index 8dccb40..eb7d693 100644 --- a/interlocks/tasks/doctor.py +++ b/interlocks/tasks/doctor.py @@ -10,13 +10,12 @@ import shutil import sys import time -import tomllib from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING from interlocks import ui -from interlocks.config import find_project_root, kv_with_source, load_config +from interlocks.config import InterlockConfigError, find_project_root, kv_with_source, load_config from interlocks.crash.storage import cache_dir as _crash_cache_dir from interlocks.detect import expected_target_interpreter from interlocks.setup_state import ( @@ -125,7 +124,7 @@ def _safe_load_config(pyproject_path: Path, failures: list[str]) -> InterlockCon """Load config, recording a failure when ``pyproject.toml`` is unreadable.""" try: return load_config() - except (OSError, tomllib.TOMLDecodeError) as exc: + except (OSError, InterlockConfigError) as exc: failures.append(f"cannot read {pyproject_path}: {exc}") return None diff --git a/tests/features/interlock_crash.feature b/tests/features/interlock_crash.feature index 55a3ca5..7435d4e 100644 --- a/tests/features/interlock_crash.feature +++ b/tests/features/interlock_crash.feature @@ -55,3 +55,13 @@ Feature: interlocks CLI crash boundary Then the exit code is not 0 And stderr does not contain "github.com/0xjgv/interlocks/issues/new" And no crash file exists in the cache directory + + # req: crash-malformed-config-no-capture + Scenario: Malformed pyproject.toml fails as clean user error without crash report + Given a project with a malformed pyproject.toml + When I run "interlocks lint" + Then the exit code is 2 + And stderr contains "interlocks:" + And stderr does not contain "Traceback" + And stderr does not contain "github.com/0xjgv/interlocks/issues/new" + And no crash file exists in the cache directory diff --git a/tests/step_defs/test_interlock_crash.py b/tests/step_defs/test_interlock_crash.py index 99c9ff8..7c36880 100644 --- a/tests/step_defs/test_interlock_crash.py +++ b/tests/step_defs/test_interlock_crash.py @@ -211,6 +211,13 @@ def _lint_failure_project(crash_session: CrashSession) -> None: _scaffold_crash_project(crash_session.project_root, broken_module=True) +@given("a project with a malformed pyproject.toml") +def _malformed_pyproject(crash_session: CrashSession) -> None: + (crash_session.project_root / "pyproject.toml").write_text( + "[invalid\nnot valid toml\n", encoding="utf-8" + ) + + @given("the first run printed a GitHub issue URL") def _first_run_printed_url(crash_run: CrashRun, crash_session: CrashSession) -> None: assert "github.com/0xjgv/interlocks/issues/new" in crash_run.stderr, ( diff --git a/tests/test_crash_boundary.py b/tests/test_crash_boundary.py index 2b994d1..188c934 100644 --- a/tests/test_crash_boundary.py +++ b/tests/test_crash_boundary.py @@ -2,6 +2,10 @@ Focus: classification semantics and invariant I6 — a bug inside the crash reporter MUST NOT mask the original exception. + +Also probes preflight / user-config error cases: +- Missing pyproject.toml exits 2 without crash capture (via existing BDD scenario). +- Malformed pyproject.toml raises InterlockConfigError, handled as user error. """ from __future__ import annotations @@ -10,7 +14,12 @@ import pytest -from interlocks.config import InterlockConfigError, InterlockUserError +from interlocks.config import ( + InterlockConfigError, + InterlockUserError, + clear_cache, + load_config, +) from interlocks.crash import boundary as boundary_mod from interlocks.crash.boundary import CrashBoundary @@ -155,3 +164,54 @@ def test_safe_load_config_returns_cfg_and_project_root_on_success( loaded, root = boundary_mod._safe_load_config() assert loaded is cfg assert root == tmp_path + + +# --------------------------------------------------------------------------- +# Preflight / user-config error classification probes +# --------------------------------------------------------------------------- + + +def test_malformed_toml_is_user_error_not_crash(tmp_path: Path) -> None: + """_load_pyproject converts TOMLDecodeError to InterlockConfigError. + + If raw TOMLDecodeError escaped, the boundary would misclassify it as an + internal crash (call stack has interlocks frames) and trigger crash capture. + InterlockConfigError is an InterlockUserError, so the boundary exits 2 cleanly. + pytest.raises(InterlockConfigError) proves no raw TOMLDecodeError escaped. + """ + from interlocks.config import _load_pyproject + + (tmp_path / "pyproject.toml").write_text("[invalid\nnot toml\n", encoding="utf-8") + with pytest.raises(InterlockConfigError, match=r"pyproject\.toml is not valid TOML"): + _load_pyproject(tmp_path) + + +def test_config_error_from_malformed_toml_exits_2_via_boundary( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """InterlockConfigError raised inside the boundary exits 2, no crash capture. + + Simulates a PREFLIGHT_EXEMPT task calling load_config() for a malformed TOML + while inside the CrashBoundary — boundary handles it as a user error, not a crash. + """ + captured: list[str] = [] + monkeypatch.setattr( + boundary_mod, + "_capture_and_transport", + lambda exc, sub: captured.append(sub), + ) + + (tmp_path / "pyproject.toml").write_text("[bad\n", encoding="utf-8") + clear_cache() + + boundary = CrashBoundary(subcommand="lint") + with pytest.raises(SystemExit) as excinfo, boundary: + load_config(tmp_path) + + assert excinfo.value.code == 2 + err = capsys.readouterr().err + assert "interlocks:" in err + assert "not valid TOML" in err + assert captured == []