Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions interlocks/behavior_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
)


Expand Down
9 changes: 6 additions & 3 deletions interlocks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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


Expand Down
5 changes: 2 additions & 3 deletions interlocks/tasks/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tests/features/interlock_crash.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions tests/step_defs/test_interlock_crash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, (
Expand Down
62 changes: 61 additions & 1 deletion tests/test_crash_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 == []
Loading