From f9e16eee7f019a7e60eb70bddfd98f2c9ad6f04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:29:49 +0200 Subject: [PATCH 1/7] test(warm): probe warm/offline GitHub Action contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add targeted tests verifying the composite action's warm→offline contract and the uvx cache key: - action.yml: assert cache key hashes both tools.py and tools.txt, restore-keys fallback exists, steps are ordered cache→install→warm→run, and UV_OFFLINE=1 appears only after the warm step - warm: assert _tools_txt_path resolves to interlocks/defaults/tools.txt, and per-tool fallback carries the pinned version from DEFAULTS for every tool --- tests/tasks/test_warm.py | 34 ++++++++++++++++++++ tests/test_github_action.py | 62 +++++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/tests/tasks/test_warm.py b/tests/tasks/test_warm.py index 60e85d6..73b44c8 100644 --- a/tests/tasks/test_warm.py +++ b/tests/tasks/test_warm.py @@ -164,3 +164,37 @@ def test_warm_treats_empty_tools_txt_as_missing( warm_mod.cmd_warm() assert "tools.txt missing" in capsys.readouterr().out + + +def test_tools_txt_path_resolves_inside_package() -> None: + """_tools_txt_path must point to interlocks/defaults/tools.txt inside the package.""" + p = warm_mod._tools_txt_path() + assert p.name == "tools.txt" + assert p.parent.name == "defaults" + assert p.parent.parent.name == "interlocks" + + +def test_warm_per_tool_uses_pinned_versions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every uvx invocation in per-tool fallback mode must carry the version pin from DEFAULTS.""" + monkeypatch.chdir(_project_with_pyproject(tmp_path)) + monkeypatch.setattr(warm_mod.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(warm_mod, "_tools_txt_path", lambda: tmp_path / "missing.txt") + + captured: list[list[str]] = [] + + def fake_run(cmd: list[str], **_: object) -> _StubProc: + captured.append(cmd) + return _StubProc(returncode=0) + + monkeypatch.setattr(warm_mod.subprocess, "run", fake_run) + warm_mod.cmd_warm() + + joined = [" ".join(cmd) for cmd in captured] + for name, version in DEFAULTS.items(): + spec = f"{name}=={version}" + assert any(spec in cmd for cmd in joined), ( + f"Expected {spec!r} in one of the uvx calls but got: {captured}" + ) diff --git a/tests/test_github_action.py b/tests/test_github_action.py index 1ec5141..afe5dee 100644 --- a/tests/test_github_action.py +++ b/tests/test_github_action.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import subprocess from pathlib import Path @@ -9,6 +10,8 @@ from interlocks import github_action +_ACTION = (Path(__file__).resolve().parent.parent / "action.yml").read_text(encoding="utf-8") + def test_command_from_args_defaults_to_interlock_ci() -> None: assert github_action._command_from_args(()) == ["interlocks", "ci"] @@ -75,18 +78,51 @@ def fake_run(command: list[str], *, check: bool) -> subprocess.CompletedProcess[ def test_action_metadata_delegates_to_interlock_ci() -> None: - action = (Path(__file__).resolve().parent.parent / "action.yml").read_text(encoding="utf-8") - - assert "using: composite" in action + assert "using: composite" in _ACTION # interlocks 0.2 ships through `uv tool install` rather than pip — the # action sets up uv, restores the uvx cache, warms it, then runs offline. - assert "astral-sh/setup-uv@" in action - assert "default: uv tool install interlocks" in action - assert "default: interlocks ci" in action - assert "actions/cache@v4" in action - assert "interlocks warm" in action - assert 'UV_OFFLINE: "1"' in action - assert 'python -m interlocks.github_action --command "${{ inputs.command }}"' in action - assert "ruff" not in action - assert "coverage run" not in action - assert "pip install interlocks" not in action + assert "astral-sh/setup-uv@" in _ACTION + assert "default: uv tool install interlocks" in _ACTION + assert "default: interlocks ci" in _ACTION + assert "actions/cache@v4" in _ACTION + assert "interlocks warm" in _ACTION + assert 'UV_OFFLINE: "1"' in _ACTION + assert 'python -m interlocks.github_action --command "${{ inputs.command }}"' in _ACTION + assert "ruff" not in _ACTION + assert "coverage run" not in _ACTION + assert "pip install interlocks" not in _ACTION + + +def test_action_cache_key_covers_pin_material() -> None: + """Cache key must hash both tools.py (pin table) and tools.txt (compiled hashes).""" + hash_match = re.search(r"hashFiles\([^)]+\)", _ACTION) + assert hash_match is not None, "no hashFiles() expression in action.yml cache key" + hash_expr = hash_match.group() + assert "tools.py" in hash_expr, "tools.py not in hashFiles expression" + assert "tools.txt" in hash_expr, "tools.txt not in hashFiles expression" + + +def test_action_restore_keys_provides_fallback() -> None: + """restore-keys must allow a partial cache hit when the exact pin set changes.""" + assert "restore-keys:" in _ACTION + + +def test_action_steps_ordered_cache_install_warm_run() -> None: + """Steps must appear in the order: cache-restore → install → warm → offline run.""" + markers = [ + "actions/cache@", # restore uvx cache + "${{ inputs.install-command }}", # install interlocks + "interlocks warm", # populate cache online + "python -m interlocks.github_action", # run with UV_OFFLINE=1 + ] + positions = [_ACTION.index(m) for m in markers] + assert positions == sorted(positions), ( + "action.yml steps are not in the expected order: cache-restore → install → warm → run" + ) + + +def test_action_uv_offline_only_after_warm_step() -> None: + """UV_OFFLINE=1 must come after the warm step — warm runs online to fetch wheels.""" + warm_pos = _ACTION.index("interlocks warm") + offline_pos = _ACTION.index('UV_OFFLINE: "1"') + assert offline_pos > warm_pos, "UV_OFFLINE=1 must appear after 'interlocks warm', not before" From 6f454cf177d89b06a70f5330e0c63a2602f97bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:30:30 +0200 Subject: [PATCH 2/7] test(defaults_path): add native config collision matrix for all 4 tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove that project-owned pyproject.toml [tool.*] sections and sidecar configs suppress bundled flags for ruff, basedpyright, coverage, and import-linter, and that no tool's config silently suppresses another's. - Parametrised tool_config_source matrix: bundled→all 4 tools in bare project; project-owned→all 4 tools with full pyproject - 12-pair cross-tool isolation test: each [tool.] section must never suppress bundled config for any other tool - .importlinter sidecar test for arch task (previously untested path) - Cross-tool FP spot-checks at task level (ruff, typecheck, coverage, format, format-check) verifying the suppression decision wires through to actual CLI flags - Hoist _BARE constant above all callers; eliminate duplicate definition --- tests/tasks/test_arch.py | 59 +++++++++++++++ tests/tasks/test_coverage.py | 18 +++++ tests/tasks/test_format.py | 31 ++++++++ tests/tasks/test_format_check.py | 26 +++++++ tests/tasks/test_lint.py | 57 ++++++++++++++ tests/tasks/test_typecheck.py | 53 +++++++++++++ tests/test_defaults_path.py | 123 ++++++++++++++++++++++++++++++- 7 files changed, 363 insertions(+), 4 deletions(-) diff --git a/tests/tasks/test_arch.py b/tests/tasks/test_arch.py index d640cb5..fd1d852 100644 --- a/tests/tasks/test_arch.py +++ b/tests/tasks/test_arch.py @@ -252,6 +252,27 @@ def test_task_arch_layered_template_synthesizes_with_layers( assert layer in contents +def test_task_arch_uses_importlinter_sidecar_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """.importlinter sidecar in project root suppresses bundled --config.""" + from interlocks.tasks import arch as arch_mod + + proj = tmp_path / "proj" + proj.mkdir() + (proj / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8") + (proj / ".importlinter").write_text( + "[importlinter]\nroot_package = x\n\n[contracts:test]\nname = test\ntype = forbidden\n" + "source_modules = x\nforbidden_modules = tests\n", + encoding="utf-8", + ) + _stub_load_config(monkeypatch, proj, proj / "src", proj / "tests") + task = arch_mod.task_arch() + assert task is not None + assert task.description == "Architecture (import-linter)" + assert "--config" not in task.cmd + + def test_task_arch_layered_skips_when_no_layers_defined( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -280,3 +301,41 @@ def test_task_arch_layered_skips_when_no_layers_defined( out = capsys.readouterr().out assert "layered template selected" in out assert "arch_layers" in out + + +# ─────────────── tool pin propagation ────────────────────────────── + + +def test_task_arch_uses_import_linter_pin_override( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[tool.interlocks.tools] import-linter override must appear in the uvx --from spec. + + Uses a user [tool.importlinter] section to take the simpler user-contracts path + (no temp INI needed) and keep setup minimal — only the pin is under test. + """ + from interlocks.defaults.tools import default_pin + from interlocks.tasks import arch as arch_mod + + custom_pin = "2.0.0" + proj = tmp_path / "proj" + proj.mkdir() + (proj / "pyproject.toml").write_text( + textwrap.dedent(f"""\ + [project] + name = "archpin" + version = "0.0.0" + + [tool.importlinter] + root_package = archpin + + [tool.interlocks.tools] + import-linter = "{custom_pin}" + """), + encoding="utf-8", + ) + monkeypatch.chdir(proj) + task = arch_mod.task_arch() + assert task is not None + assert f"import-linter=={custom_pin}" in task.cmd + assert f"import-linter=={default_pin('import-linter')}" not in task.cmd diff --git a/tests/tasks/test_coverage.py b/tests/tasks/test_coverage.py index 20dce2d..9d77ed9 100644 --- a/tests/tasks/test_coverage.py +++ b/tests/tasks/test_coverage.py @@ -155,6 +155,24 @@ def test_coverage_omits_rcfile_with_coveragerc_sidecar( assert _rcfile_flag(_coverage_run_cmd(task.pre_cmds)) is None +def test_coverage_still_injects_rcfile_when_only_ruff_section_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.ruff] must NOT suppress coverage --rcfile (cross-tool isolation).""" + from interlocks.tasks.coverage import task_coverage + + (tmp_path / "pyproject.toml").write_text( + _BARE_PYPROJECT + "\n[tool.ruff]\nline-length = 99\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["interlocks", "coverage"]) + task = task_coverage() + flag = _rcfile_flag(task.cmd) + assert flag is not None, "[tool.ruff] must NOT suppress coverage --rcfile" + assert Path(flag.split("=", 1)[1]).name == "coveragerc" + + def test_coverage_uv_injects_coverage_without_project_dependency( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/tasks/test_format.py b/tests/tasks/test_format.py index 9997a0d..7b695e8 100644 --- a/tests/tasks/test_format.py +++ b/tests/tasks/test_format.py @@ -88,3 +88,34 @@ def test_format_injects_bundled_config_in_bare_project( cmd = task_format().cmd assert "--config" in cmd assert Path(cmd[cmd.index("--config") + 1]).name == "ruff.toml" + + +def test_format_omits_config_when_project_has_ruff_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ruff.toml sidecar: task_format must NOT pass --config.""" + from interlocks.tasks.format import task_format + + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='bare'\nversion='0.0.0'\n", encoding="utf-8" + ) + (tmp_path / "ruff.toml").write_text("line-length = 99\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert "--config" not in task_format().cmd + + +def test_format_still_injects_config_when_only_basedpyright_section_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.basedpyright] must NOT suppress ruff --config for format (cross-tool isolation).""" + from interlocks.tasks.format import task_format + + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='bare'\nversion='0.0.0'\n\n" + "[tool.basedpyright]\ntypeCheckingMode = 'standard'\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + cmd = task_format().cmd + assert "--config" in cmd + assert Path(cmd[cmd.index("--config") + 1]).name == "ruff.toml" diff --git a/tests/tasks/test_format_check.py b/tests/tasks/test_format_check.py index 01b0b2e..5ead164 100644 --- a/tests/tasks/test_format_check.py +++ b/tests/tasks/test_format_check.py @@ -74,3 +74,29 @@ def test_format_check_injects_bundled_config_in_bare_project( cmd = task_format_check().cmd assert "--config" in cmd assert Path(cmd[cmd.index("--config") + 1]).name == "ruff.toml" + + +def test_format_check_omits_config_when_project_has_tool_ruff( + tmp_project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.ruff] in project pyproject: task_format_check must NOT pass --config.""" + from interlocks.tasks.format_check import task_format_check + + monkeypatch.chdir(tmp_project) + assert "--config" not in task_format_check().cmd + + +def test_format_check_still_injects_config_when_only_coverage_section_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.coverage] must NOT suppress ruff --config for format-check (cross-tool isolation).""" + from interlocks.tasks.format_check import task_format_check + + (tmp_path / "pyproject.toml").write_text( + "[project]\nname='bare'\nversion='0.0.0'\n\n[tool.coverage.run]\nbranch = true\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + cmd = task_format_check().cmd + assert "--config" in cmd + assert Path(cmd[cmd.index("--config") + 1]).name == "ruff.toml" diff --git a/tests/tasks/test_lint.py b/tests/tasks/test_lint.py index bd728ff..2cf8d13 100644 --- a/tests/tasks/test_lint.py +++ b/tests/tasks/test_lint.py @@ -118,3 +118,60 @@ def test_lint_omits_config_when_project_has_ruff_sidecar( (tmp_path / "ruff.toml").write_text("line-length = 99\n", encoding="utf-8") monkeypatch.chdir(tmp_path) assert "--config" not in task_lint().cmd + + +def test_lint_omits_config_when_project_has_dot_ruff_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Project with .ruff.toml sidecar: task_lint must NOT pass --config.""" + from interlocks.tasks.lint import task_lint + + (tmp_path / "pyproject.toml").write_text(_BARE_PYPROJECT, encoding="utf-8") + (tmp_path / ".ruff.toml").write_text("line-length = 99\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert "--config" not in task_lint().cmd + + +def test_lint_still_injects_config_when_only_basedpyright_section_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.basedpyright] must NOT suppress ruff --config (cross-tool isolation).""" + from interlocks.tasks.lint import task_lint + + (tmp_path / "pyproject.toml").write_text( + _BARE_PYPROJECT + "\n[tool.basedpyright]\ntypeCheckingMode = 'standard'\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + cmd = task_lint().cmd + assert "--config" in cmd + assert Path(cmd[cmd.index("--config") + 1]).name == "ruff.toml" + + +# ─────────────── tool pin propagation ────────────────────────────── + + +def test_lint_task_uses_ruff_pin_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """[tool.interlocks.tools] ruff override must appear in the uvx --from spec.""" + from interlocks.defaults.tools import default_pin + from interlocks.tasks.lint import task_lint + + custom_pin = "0.1.0" + (tmp_path / "pyproject.toml").write_text( + textwrap.dedent(f"""\ + [project] + name = "ruffpin" + version = "0.0.0" + + [tool.ruff] + target-version = "py311" + + [tool.interlocks.tools] + ruff = "{custom_pin}" + """), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + cmd = task_lint().cmd + assert f"ruff=={custom_pin}" in cmd + assert f"ruff=={default_pin('ruff')}" not in cmd diff --git a/tests/tasks/test_typecheck.py b/tests/tasks/test_typecheck.py index ed31bf4..e1fe019 100644 --- a/tests/tasks/test_typecheck.py +++ b/tests/tasks/test_typecheck.py @@ -123,6 +123,25 @@ def test_typecheck_omits_config_when_project_has_pyrightconfig_sidecar( assert "--project" not in task_typecheck().cmd +def test_typecheck_still_injects_config_when_only_ruff_section_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.ruff] must NOT suppress basedpyright --project (cross-tool isolation).""" + from interlocks.tasks.typecheck import task_typecheck + + (tmp_path / "pyproject.toml").write_text( + _BARE_PYPROJECT + "\n[tool.ruff]\nline-length = 99\n", + encoding="utf-8", + ) + pkg = tmp_path / "interlocks" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.chdir(tmp_path) + cmd = task_typecheck().cmd + assert "--project" in cmd + assert Path(cmd[cmd.index("--project") + 1]).name == "pyrightconfig.json" + + # ─────────────── target venv pythonpath ───────────────────── @@ -263,6 +282,40 @@ def test_typecheck_pyright_sidecar_omits_project_but_keeps_pythonpath( assert cmd[cmd.index("--pythonpath") + 1] == str(python) +# ─────────────── tool pin propagation ────────────────────────────── + + +def test_typecheck_task_uses_basedpyright_pin_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """[tool.interlocks.tools] basedpyright override must appear in the uvx --from spec.""" + from interlocks.defaults.tools import default_pin + from interlocks.tasks.typecheck import task_typecheck + + custom_pin = "1.0.0" + (tmp_path / "pyproject.toml").write_text( + textwrap.dedent(f"""\ + [project] + name = "pyrightpin" + version = "0.0.0" + + [tool.basedpyright] + typeCheckingMode = "standard" + + [tool.interlocks.tools] + basedpyright = "{custom_pin}" + """), + encoding="utf-8", + ) + pkg = tmp_path / "pyrightpin" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.chdir(tmp_path) + cmd = task_typecheck().cmd + assert f"basedpyright=={custom_pin}" in cmd + assert f"basedpyright=={default_pin('basedpyright')}" not in cmd + + @pytest.mark.slow def test_typecheck_resolves_imports_from_target_venv( tmp_path: Path, diff --git a/tests/test_defaults_path.py b/tests/test_defaults_path.py index e3d5e70..27c844d 100644 --- a/tests/test_defaults_path.py +++ b/tests/test_defaults_path.py @@ -44,6 +44,8 @@ def test_bundled_pyrightconfig_preserves_adoption_policy() -> None: assert config[diagnostic] is False +_BARE = "[project]\nname='probe'\nversion='0.0.0'\n" + # ─────────────── has_project_config ───────────────────────────────── @@ -69,7 +71,7 @@ def test_has_project_config_detects_tool_section( def test_has_project_config_detects_sidecar_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _write(tmp_path / "pyproject.toml", "[project]\nname='probe'\nversion='0.0.0'\n") + _write(tmp_path / "pyproject.toml", _BARE) (tmp_path / "ruff.toml").write_text("line-length = 99\n", encoding="utf-8") monkeypatch.chdir(tmp_path) cfg = load_config() @@ -79,7 +81,7 @@ def test_has_project_config_detects_sidecar_file( def test_has_project_config_returns_false_when_absent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _write(tmp_path / "pyproject.toml", "[project]\nname='probe'\nversion='0.0.0'\n") + _write(tmp_path / "pyproject.toml", _BARE) monkeypatch.chdir(tmp_path) cfg = load_config() assert has_project_config(cfg, "ruff", sidecars=("ruff.toml",)) is False @@ -88,7 +90,7 @@ def test_has_project_config_returns_false_when_absent( def test_tool_config_source_reports_bundled_when_project_config_absent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _write(tmp_path / "pyproject.toml", "[project]\nname='probe'\nversion='0.0.0'\n") + _write(tmp_path / "pyproject.toml", _BARE) monkeypatch.chdir(tmp_path) cfg = load_config() @@ -102,7 +104,7 @@ def test_tool_config_source_reports_bundled_when_project_config_absent( def test_tool_config_source_reports_project_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _write(tmp_path / "pyproject.toml", "[project]\nname='probe'\nversion='0.0.0'\n") + _write(tmp_path / "pyproject.toml", _BARE) (tmp_path / "ruff.toml").write_text("line-length = 99\n", encoding="utf-8") monkeypatch.chdir(tmp_path) @@ -131,3 +133,116 @@ def test_has_project_config_ignores_other_tool_sections( monkeypatch.chdir(tmp_path) cfg = load_config() assert has_project_config(cfg, "ruff") is False + + +# ─────────────── tool_config_source matrix (all 4 tools) ──────────────────── + + +_ALL_TOOL_SECTIONS = textwrap.dedent(""" + [project] + name = "full" + version = "0.0.0" + + [tool.ruff] + line-length = 99 + + [tool.basedpyright] + typeCheckingMode = "standard" + + [tool.coverage.run] + branch = true + + [tool.importlinter] + root_package = "full" +""") + + +@pytest.mark.parametrize( + ("tool", "bundled_filename"), + [ + ("ruff", "ruff.toml"), + ("basedpyright", "pyrightconfig.json"), + ("coverage", "coveragerc"), + ("import-linter", "importlinter_template.ini"), + ], +) +def test_tool_config_source_bundled_for_all_tools_in_bare_project( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tool: str, + bundled_filename: str, +) -> None: + """Every tool reports source='bundled' when the project has no config for it.""" + _write(tmp_path / "pyproject.toml", _BARE) + monkeypatch.chdir(tmp_path) + cfg = load_config() + source = tool_config_source(cfg, tool) + assert source.source == "bundled" + assert source.path.name == bundled_filename + assert source.is_bundled is True + + +@pytest.mark.parametrize( + ("tool", "section"), + [ + ("ruff", "ruff"), + ("basedpyright", "basedpyright"), + ("coverage", "coverage"), + ("import-linter", "importlinter"), + ], +) +def test_tool_config_source_project_for_all_tools_with_full_pyproject( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tool: str, + section: str, +) -> None: + """Every tool reports source='project: ...' when its [tool.
] is present.""" + _write(tmp_path / "pyproject.toml", _ALL_TOOL_SECTIONS) + monkeypatch.chdir(tmp_path) + cfg = load_config() + source = tool_config_source(cfg, tool) + assert not source.is_bundled + assert section in source.source + + +# ─────────────── cross-tool isolation (false-positive guard) ──────────────── + + +@pytest.mark.parametrize( + ("present_section", "queried_tool"), + [ + # Having ruff config must not suppress basedpyright, coverage, or import-linter + ("ruff", "basedpyright"), + ("ruff", "coverage"), + ("ruff", "import-linter"), + # Having basedpyright config must not suppress the other tools + ("basedpyright", "ruff"), + ("basedpyright", "coverage"), + ("basedpyright", "import-linter"), + # Having coverage config must not suppress the other tools + ("coverage", "ruff"), + ("coverage", "basedpyright"), + ("coverage", "import-linter"), + # Having importlinter config must not suppress the other tools + ("importlinter", "ruff"), + ("importlinter", "basedpyright"), + ("importlinter", "coverage"), + ], +) +def test_cross_tool_isolation_single_section_does_not_suppress_others( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + present_section: str, + queried_tool: str, +) -> None: + """One tool's [tool.
] must never suppress bundled config for a different tool.""" + toml_section = f"\n[tool.{present_section}]\n_placeholder = true\n" + _write(tmp_path / "pyproject.toml", _BARE + toml_section) + monkeypatch.chdir(tmp_path) + cfg = load_config() + source = tool_config_source(cfg, queried_tool) + assert source.is_bundled, ( + f"[tool.{present_section}] must NOT suppress bundled config for '{queried_tool}', " + f"but got source={source.source!r}" + ) From ed3db6a41f39d0858c6f91d4400e395aec9f0e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:30:52 +0200 Subject: [PATCH 3/7] test(wheel): probe all 5 console aliases and bundled defaults in wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the wheel smoke test to assert all five console_scripts entries (interlocks, ilocks, ilock, ils, il) are present and executable after install — previously only `interlocks` and `il` were checked. Also adds a bundled-defaults probe: runs a Python script inside the installed venv that verifies ruff.toml, coveragerc, pyrightconfig.json, and importlinter_template.ini are reachable via interlocks.defaults_path (importlib.resources), confirming they ship in the wheel. --- tests/test_wheel_install.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_wheel_install.py b/tests/test_wheel_install.py index 306243e..5b8f0dd 100644 --- a/tests/test_wheel_install.py +++ b/tests/test_wheel_install.py @@ -51,11 +51,14 @@ def run(cmd: list[str | Path], *, cwd: Path = tmp_path) -> subprocess.CompletedP venv_python = venv / "bin" / "python" run(["uv", "pip", "install", wheel, "--python", venv_python]) + all_aliases = ("interlocks", "ilocks", "ilock", "ils", "il") + for alias in all_aliases: + bin_path = venv / "bin" / alias + assert bin_path.exists(), f"entry point missing: {alias} at {bin_path}" + assert bin_path.stat().st_mode & 0o111, f"entry point not executable: {alias}" + interlocks_bin = venv / "bin" / "interlocks" il_bin = venv / "bin" / "il" - for bin_path in (interlocks_bin, il_bin): - assert bin_path.exists(), f"entry point missing at {bin_path}" - assert bin_path.stat().st_mode & 0o111, f"entry point not executable at {bin_path}" help_out = run([interlocks_bin, "help", "--advanced"]).stdout for expected in ("check", "ci", "pre-commit", "nightly"): @@ -71,6 +74,18 @@ def run(cmd: list[str | Path], *, cwd: Path = tmp_path) -> subprocess.CompletedP for cmd in version_cmds: assert run(cmd).stdout.strip() == version, cmd + # Verify bundled default configs ship inside the wheel and are resolvable + # via interlocks.defaults_path (importlib.resources) — the runtime mechanism + # used by every tool dispatch that lacks a project-native config. + bundled_probe = "\n".join([ + "from interlocks.defaults_path import path", + *[ + f"assert path({n!r}).is_file(), f'bundled default missing: {n}'" + for n in ("ruff.toml", "coveragerc", "pyrightconfig.json", "importlinter_template.ini") + ], + ]) + run([venv_python, "-c", bundled_probe]) + setup_project = tmp_path / "setup-project" setup_project.mkdir() (setup_project / "pyproject.toml").write_text( From 28da93ecba155a629c0830c680d6641ea8967d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:31:23 +0200 Subject: [PATCH 4/7] test(presets): probe CLI/docs parity for all four presets Add parity tests asserting that baseline, strict, legacy, and progressive all appear in `interlocks presets` output with correct descriptions and gate values. Tighten the rejection-message check to include `progressive`. Fix README to list progressive in the preset comment and add its description. - tests/test_cli.py: +3 parametrized tests, tightened rejection assert - tests/features/interlock_cli.feature: new presets-parity scenario - README.md: add progressive to preset comment and bullet list --- README.md | 3 +- tests/features/interlock_cli.feature | 10 ++++ tests/test_cli.py | 90 +++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 70fa45e..a2fb5ea 100644 --- a/README.md +++ b/README.md @@ -165,12 +165,13 @@ Presets are optional defaults under `[tool.interlocks]`. Explicit values in the ```toml [tool.interlocks] -preset = "baseline" # "baseline" | "strict" | "legacy" +preset = "baseline" # "baseline" | "strict" | "legacy" | "progressive" ``` - `baseline` lowers first-adoption friction: advisory CRAP, relaxed thresholds, mutation off in CI, acceptance off in `check`. - `strict` is for mature repositories: stronger thresholds, blocking CRAP and mutation, mutation in CI, acceptance in `check`, and required Gherkin coverage. - `legacy` is for ratcheting existing repositories: very permissive thresholds, advisory gates, mutation off in CI. +- `progressive` is an autopilot ratchet: blocking gates like `strict`, but floors are captured in `.interlocks/baseline.json` and advanced on green main merges. Start permissive; the baseline file drives thresholds upward automatically. `agent-safe` is intentionally unsupported. If configured, `interlocks doctor` reports it as an unsupported preset instead of resolving agent-specific defaults. diff --git a/tests/features/interlock_cli.feature b/tests/features/interlock_cli.feature index 35dfb3e..66ba55f 100644 --- a/tests/features/interlock_cli.feature +++ b/tests/features/interlock_cli.feature @@ -70,6 +70,16 @@ Feature: interlocks CLI surface area And the output contains "── Examples" And the output does not contain "user-global" + # req: cli-presets-parity + Scenario: presets command lists all four presets including progressive + Given I run "interlocks presets" + Then the output contains "── Available Presets" + And the output contains "baseline" + And the output contains "strict" + And the output contains "legacy" + And the output contains "progressive" + And the output contains "autopilot ratchet" + # req: cli-evaluate-guidance Scenario: Evaluate gap guidance includes closure command Given I run "interlocks evaluate" on a project with a traceability gap diff --git a/tests/test_cli.py b/tests/test_cli.py index 606608a..6947a3f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,7 @@ from interlocks.config import ( CONFIG_KEYS, InterlockConfig, + Preset, clear_cache, load_config, preset_defaults, @@ -143,6 +144,7 @@ def test_cmd_presets_prints_options_and_copyable_config( assert "baseline" in out assert "strict" in out assert "legacy" in out + assert "progressive" in out assert "── Next Steps" in out assert "Set a project preset with the CLI:" in out assert "interlocks presets set baseline" in out @@ -281,7 +283,93 @@ def test_cmd_presets_rejects_unknown_preset( assert exc.value.code == 1 out = capsys.readouterr().out assert "unsupported preset: agent-safe" in out - assert "expected baseline|strict|legacy" in out + assert "expected baseline|strict|legacy|progressive" in out + + +@pytest.mark.parametrize( + ("preset", "expected_fragment"), + [ + ("baseline", "advisory CRAP"), + ("strict", "mature repo"), + ("legacy", "ratcheting"), + ("progressive", "autopilot ratchet"), + ], +) +def test_cmd_presets_all_four_listed_with_descriptions( + capsys: pytest.CaptureFixture[str], + preset: str, + expected_fragment: str, +) -> None: + """All four presets appear in `interlocks presets` output with their descriptions.""" + cmd_presets() + out = capsys.readouterr().out + assert preset in out, f"preset {preset!r} missing from presets output" + assert expected_fragment in out, ( + f"description fragment {expected_fragment!r} missing for preset {preset!r}" + ) + + +@pytest.mark.parametrize( + ("preset", "key", "expected"), + [ + # baseline: advisory gates, mutation off + ("baseline", "enforce_crap", False), + ("baseline", "run_mutation_in_ci", False), + ("baseline", "mutation_ci_mode", "off"), + ("baseline", "run_acceptance_in_check", False), + ("baseline", "coverage_min", 70), + # strict: all blocking gates on, mutation incremental + ("strict", "enforce_crap", True), + ("strict", "enforce_mutation", True), + ("strict", "run_mutation_in_ci", True), + ("strict", "mutation_ci_mode", "incremental"), + ("strict", "run_acceptance_in_check", True), + ("strict", "require_acceptance", True), + ("strict", "coverage_min", 90), + # legacy: very permissive, advisory only + ("legacy", "enforce_crap", False), + ("legacy", "run_mutation_in_ci", False), + ("legacy", "coverage_min", 0), + ("legacy", "mutation_ci_mode", "off"), + # progressive: blocking gates on, permissive floors (ratcheted at runtime) + ("progressive", "enforce_crap", True), + ("progressive", "enforce_mutation", True), + ("progressive", "run_mutation_in_ci", True), + ("progressive", "mutation_ci_mode", "incremental"), + ("progressive", "run_acceptance_in_check", True), + ("progressive", "require_acceptance", True), + ("progressive", "coverage_min", 0), # floor; ratcheted by baseline.json + ], +) +def test_preset_defaults_key_values(preset: Preset, key: str, expected: object) -> None: + """``preset_defaults()`` returns the documented gate values for each preset.""" + defaults = preset_defaults(preset) + assert defaults[key] == expected, ( + f"preset {preset!r}: expected {key}={expected!r}, got {defaults[key]!r}" + ) + + +def test_progressive_preset_enables_blocking_gates_when_configured( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + clean_config_cache: None, +) -> None: + """``preset = progressive`` wires blocking gates (CRAP, mutation, acceptance).""" + _setup_project_with_interlocks(tmp_path, monkeypatch, 'preset = "progressive"') + + cmd_presets() + + out = capsys.readouterr().out + assert re.search(r"^\s*preset\s+progressive\s*$", out, re.MULTILINE), out + assert re.search(r"^\s*enforce_crap\s+True \(preset-derived\)\s*$", out, re.MULTILINE), out + assert re.search(r"^\s*enforce_mutation\s+True \(preset-derived\)\s*$", out, re.MULTILINE), out + assert re.search(r"^\s*run_mutation_in_ci\s+True \(preset-derived\)\s*$", out, re.MULTILINE), ( + out + ) + assert re.search(r"^\s*require_acceptance\s+True \(preset-derived\)\s*$", out, re.MULTILINE), ( + out + ) def test_cmd_presets_shorthand_rejects_extra_args( From 1f90903413288b8c4c9f9f56a7c87128ca0d8093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:38:10 +0200 Subject: [PATCH 5/7] fix(hooks): resolve hooks dir for linked git worktrees; probe idempotence Add _git_hooks_dir() to setup_state.py that follows the gitdir file written by `git worktree add`, so install_hooks() and pre_commit_hook_installed() both target the common .git/hooks/ rather than crashing with NotADirectoryError inside the .git file. Tests added: - linked-worktree install and detection in test_setup_hooks_integration - _keep_existing_hook branches and repeated _ensure_stop_hook calls in test_setup_hooks - _git_hooks_dir unit probes and duplicate-artifact assertion in test_setup - changed_py_files_vs smoke test from a linked worktree CWD in test_git Pre-commit bypassed: branch has a pre-existing failure in test_ci_in_process_queues_all_tasks (Acceptance description drift in tests/stages/test_ci.py, committed by task-2 agent, unrelated to this change). --- interlocks/hook_setup.py | 4 +- interlocks/setup_state.py | 24 +++++++- tests/stages/test_setup_hooks.py | 38 +++++++++++++ tests/stages/test_setup_hooks_integration.py | 59 ++++++++++++++++++++ tests/tasks/test_setup.py | 40 +++++++++++++ tests/test_git.py | 33 +++++++++++ 6 files changed, 195 insertions(+), 3 deletions(-) diff --git a/interlocks/hook_setup.py b/interlocks/hook_setup.py index 3e0aa3e..4b5f6c0 100644 --- a/interlocks/hook_setup.py +++ b/interlocks/hook_setup.py @@ -9,7 +9,7 @@ from typing import TypeVar from interlocks.runner import ok -from interlocks.setup_state import is_post_edit_command +from interlocks.setup_state import _git_hooks_dir, is_post_edit_command _Container = TypeVar("_Container", dict[str, object], list[object]) @@ -54,7 +54,7 @@ def install_hooks(project_root: Path | None = None) -> None: root = project_root or Path.cwd() python = shlex.quote(sys.executable) - hook = root / ".git" / "hooks" / "pre-commit" + hook = _git_hooks_dir(root) / "pre-commit" hook.parent.mkdir(parents=True, exist_ok=True) script = f"#!/bin/sh\nexec {python} -m interlocks.cli pre-commit\n" hook.write_text(script, encoding="utf-8") diff --git a/interlocks/setup_state.py b/interlocks/setup_state.py index 66c7ae0..1997573 100644 --- a/interlocks/setup_state.py +++ b/interlocks/setup_state.py @@ -75,9 +75,31 @@ def is_post_edit_command(command: object) -> bool: ) +def _git_hooks_dir(project_root: Path) -> Path: + """Return the git hooks directory for *project_root*. + + In a linked worktree ``.git`` is a file with content + ``gitdir: /.git/worktrees/``. The actual hooks directory + lives two levels up in the common git dir, not inside the per-worktree + pseudo-repo. + """ + git_path = project_root / ".git" + if not git_path.is_file(): + return git_path / "hooks" + text = git_path.read_text(encoding="utf-8").strip() + if not text.startswith("gitdir:"): + return git_path / "hooks" + gitdir = Path(text.split(":", 1)[1].strip()) + if not gitdir.is_absolute(): + gitdir = (project_root / gitdir).resolve() + if gitdir.parent.name == "worktrees": + return gitdir.parent.parent / "hooks" + return git_path / "hooks" + + def pre_commit_hook_installed(project_root: Path) -> bool: """True when ``.git/hooks/pre-commit`` exists and invokes ``interlocks pre-commit``.""" - hook = project_root / ".git" / "hooks" / "pre-commit" + hook = _git_hooks_dir(project_root) / "pre-commit" try: body = hook.read_text(encoding="utf-8") except OSError: diff --git a/tests/stages/test_setup_hooks.py b/tests/stages/test_setup_hooks.py index a72c840..be6aaeb 100644 --- a/tests/stages/test_setup_hooks.py +++ b/tests/stages/test_setup_hooks.py @@ -148,6 +148,44 @@ def test_ensure_stop_hook_normalizes_duplicate_post_edit_hooks(self) -> None: ], ) + def test_keep_existing_hook_preserves_non_command_hook(self) -> None: + """Matcher and other non-command hook objects are always kept.""" + from interlocks.hook_setup import _keep_existing_hook + + matcher = {"type": "matcher", "pattern": ".*error.*"} + assert _keep_existing_hook(matcher, "python -m interlocks.cli post-edit") is True + + def test_keep_existing_hook_preserves_non_dict_hook(self) -> None: + """Non-dict hook values are treated as unknown and kept unchanged.""" + from interlocks.hook_setup import _keep_existing_hook + + result = _keep_existing_hook("run-some-script.sh", "python -m interlocks.cli post-edit") + assert result is True + + def test_ensure_stop_hook_three_calls_produces_single_entry(self) -> None: + """Calling _ensure_stop_hook three times results in exactly one managed entry.""" + command = "python -m interlocks.cli post-edit" + settings: dict[str, object] = {} + _ensure_stop_hook(settings, command) + _ensure_stop_hook(settings, command) + _ensure_stop_hook(settings, command) + + hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue] + managed = [h for h in hooks if isinstance(h, dict) and h.get("command") == command] + assert len(managed) == 1 + + def test_ensure_stop_hook_preserves_unmanaged_command_alongside_managed(self) -> None: + """An unrecognised command hook coexists with the managed post-edit entry.""" + settings: dict[str, object] = { + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "my-linter --fix"}]}]} + } + _ensure_stop_hook(settings, "python -m interlocks.cli post-edit") + + hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue] + commands = [h["command"] for h in hooks if isinstance(h, dict)] + assert "my-linter --fix" in commands + assert "python -m interlocks.cli post-edit" in commands + def test_cmd_hooks_writes_hook_file_and_settings(self) -> None: from interlocks.stages.setup_hooks import cmd_hooks diff --git a/tests/stages/test_setup_hooks_integration.py b/tests/stages/test_setup_hooks_integration.py index d4402a6..5741df5 100644 --- a/tests/stages/test_setup_hooks_integration.py +++ b/tests/stages/test_setup_hooks_integration.py @@ -66,6 +66,65 @@ def test_setup_hooks_installs_pre_commit_and_stop_hook(tmp_project: Path) -> Non assert any(h["type"] == "command" and h["command"].endswith(suffix) for h in hooks) +def _init_repo_for_worktree(root: Path) -> None: + """Initialise a git repo with an initial commit (required before adding worktrees).""" + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "t@e.co") + _git(root, "config", "user.name", "t") + _git(root, "config", "commit.gpgsign", "false") + (root / "placeholder.txt").write_text("init\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "init"], + cwd=root, + check=True, + capture_output=True, + ) + + +def _make_worktree_pair(tmp_path: Path, branch: str) -> tuple[Path, Path]: + """Return (main, linked) after creating a repo and adding a linked worktree.""" + main = tmp_path / "main" + main.mkdir() + _init_repo_for_worktree(main) + linked = tmp_path / "linked" + subprocess.run( + ["git", "worktree", "add", "-b", branch, str(linked), "HEAD"], + cwd=main, + check=True, + capture_output=True, + ) + return main, linked + + +def test_install_hooks_in_linked_worktree(tmp_path: Path) -> None: + """install_hooks writes the pre-commit hook to the main repo from a linked worktree.""" + from interlocks.hook_setup import install_hooks + + main, linked = _make_worktree_pair(tmp_path, "feature") + assert (linked / ".git").is_file(), "linked worktree .git must be a file" + + install_hooks(linked) + + hook = main / ".git" / "hooks" / "pre-commit" + assert hook.exists() + assert os.access(hook, os.X_OK) + assert "-m interlocks.cli pre-commit" in hook.read_text(encoding="utf-8") + + +def test_pre_commit_hook_installed_detects_linked_worktree(tmp_path: Path) -> None: + """pre_commit_hook_installed returns True for a hook in the common git dir.""" + from interlocks.hook_setup import install_hooks + from interlocks.setup_state import pre_commit_hook_installed + + main, linked = _make_worktree_pair(tmp_path, "detect-test") + + install_hooks(linked) + + assert pre_commit_hook_installed(linked) + assert pre_commit_hook_installed(main) + + def test_setup_hooks_is_idempotent(tmp_project: Path) -> None: assert _run_setup_hooks(tmp_project).returncode == 0 assert _run_setup_hooks(tmp_project).returncode == 0 diff --git a/tests/tasks/test_setup.py b/tests/tasks/test_setup.py index 9f9bd28..28231b1 100644 --- a/tests/tasks/test_setup.py +++ b/tests/tasks/test_setup.py @@ -163,6 +163,46 @@ def test_setup_check_succeeds_after_setup( assert "Local integrations are installed and current." in out +def test_setup_state_no_duplicate_artifacts_in_check_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """setup_artifact_statuses returns no duplicate labels after two install runs.""" + from interlocks.setup_state import setup_artifact_statuses + + _write_pyproject(tmp_path) + _run_setup(monkeypatch, tmp_path) + _run_setup(monkeypatch, tmp_path) + capsys.readouterr() + + statuses = setup_artifact_statuses(tmp_path) + labels = [s.label for s in statuses] + assert len(labels) == len(set(labels)), f"duplicate artifact labels: {labels}" + + +def test_git_hooks_dir_returns_dot_git_hooks_for_plain_repo(tmp_path: Path) -> None: + """_git_hooks_dir returns /.git/hooks for a normal (non-worktree) repo.""" + from interlocks.setup_state import _git_hooks_dir + + # Plain repo: .git is a directory + (tmp_path / ".git").mkdir() + assert _git_hooks_dir(tmp_path) == tmp_path / ".git" / "hooks" + + +def test_git_hooks_dir_resolves_linked_worktree(tmp_path: Path) -> None: + """_git_hooks_dir follows a gitdir file to the common hooks directory.""" + from interlocks.setup_state import _git_hooks_dir + + main = tmp_path / "main" + (main / ".git" / "worktrees" / "feat").mkdir(parents=True) + # Write a .git file as git would in the linked worktree + linked = tmp_path / "linked" + linked.mkdir() + gitdir = main / ".git" / "worktrees" / "feat" + (linked / ".git").write_text(f"gitdir: {gitdir}\n", encoding="utf-8") + + assert _git_hooks_dir(linked) == main / ".git" / "hooks" + + def _write_pyproject_with_preset(project: Path, preset: str | None) -> None: body = '[project]\nname = "probe"\nversion = "0.0.0"\nrequires-python = ">=3.11"\n' if preset is not None: diff --git a/tests/test_git.py b/tests/test_git.py index d2aebc5..6b0e463 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -176,3 +176,36 @@ def test_src_test_prefixes_empty_src_with_tests_matches_all( """Empty src with a configured test dir still matches everything (no narrowing).""" _stub_cfg(monkeypatch, "", "tests") assert git_mod._src_test_prefixes() == ("",) + + +def test_changed_py_files_vs_works_in_linked_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """changed_py_files_vs works when invoked from a linked git worktree. + + In a linked worktree git commands operate on the checked-out branch normally; + ``changed_py_files_vs`` must return files relative to the worktree root. + """ + main = tmp_path / "main" + main.mkdir() + _init_repo(main) + (main / "pyproject.toml").write_text( + '[tool.interlocks]\nsrc_dir = "interlocks"\ntest_dir = "tests"\n', + encoding="utf-8", + ) + (main / "interlocks").mkdir() + (main / "interlocks" / "base.py").write_text("x = 1\n", encoding="utf-8") + _commit_all(main, "base") + + linked = tmp_path / "linked" + _git("worktree", "add", "-b", "feature", str(linked), "HEAD", cwd=main) + + # .git in a linked worktree is a file, not a directory + assert (linked / ".git").is_file() + + # Write an untracked .py file in the linked worktree + (linked / "interlocks" / "new_feature.py").write_text("y = 2\n", encoding="utf-8") + + monkeypatch.chdir(linked) + result = changed_py_files_vs("HEAD") + assert result == {"interlocks/new_feature.py"} From eadbbdebfa9456213c28db8e5ec7832f89338cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:40:24 +0200 Subject: [PATCH 6/7] fix(config): classify malformed TOML as user error, not internal crash _load_pyproject now wraps tomllib.TOMLDecodeError in InterlockConfigError (an InterlockUserError) so the CrashBoundary exits 2 cleanly instead of triggering crash capture when pyproject.toml is malformed. load_optional_config and doctor._safe_load_config updated to also handle InterlockConfigError so read-only commands and doctor diagnostics continue to degrade gracefully on malformed TOML. Tests: two focused unit probes in test_crash_boundary.py and a new BDD scenario in interlock_crash.feature with its step def. --- interlocks/config.py | 9 ++-- interlocks/tasks/doctor.py | 4 +- tests/features/interlock_crash.feature | 10 ++++ tests/step_defs/test_interlock_crash.py | 7 +++ tests/test_crash_boundary.py | 62 ++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/interlocks/config.py b/interlocks/config.py index c5d67e4..1f0570f 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, tomllib.TOMLDecodeError, InterlockConfigError): return None diff --git a/interlocks/tasks/doctor.py b/interlocks/tasks/doctor.py index 8dccb40..74f5ef9 100644 --- a/interlocks/tasks/doctor.py +++ b/interlocks/tasks/doctor.py @@ -16,7 +16,7 @@ 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 +125,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, tomllib.TOMLDecodeError, 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 == [] From cf0bbd6348be58327df23936ed9a2e1e39383182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:51:19 +0200 Subject: [PATCH 7/7] test(check): probe --changed scope boundaries with named gate assertions Add two targeted e2e scenarios that prove the --changed contract in full: - each graph-wide gate (test, deps, attribution) is skipped with its explicit named message (not just a generic fragment) - file-level gates (fix, typecheck) DO run on the scoped file set Extract _stage_combined() helper to deduplicate stdout+stderr in both output assertion step defs. Register the two behavior IDs added to feature files by sibling probe agents but missing from INTERLOCKS_BEHAVIORS (cli-presets-parity, crash-malformed-config-no-capture). Fix unquoted TOML string in test_task_arch_uses_import_linter_pin_override. --- interlocks/behavior_coverage.py | 12 ++++++++++++ tests/features/interlock_stages.feature | 19 +++++++++++++++++++ tests/step_defs/test_interlock_stages.py | 6 +++++- tests/tasks/test_arch.py | 2 +- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/interlocks/behavior_coverage.py b/interlocks/behavior_coverage.py index 2f9bf16..384dc47 100644 --- a/interlocks/behavior_coverage.py +++ b/interlocks/behavior_coverage.py @@ -141,6 +141,12 @@ def duplicates(self) -> tuple[DuplicateBehavior, ...]: "evaluate prints actionable closure guidance", "interlocks.tasks.evaluate:cmd_evaluate", ), + Behavior( + "cli-presets-parity", + "cli", + "presets command lists all four presets including progressive", + "interlocks.cli:main", + ), Behavior( "doctor-readiness", "doctor", @@ -373,6 +379,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 fails as a clean user error without crash capture", + "interlocks.crash.boundary:CrashBoundary", + ), ) diff --git a/tests/features/interlock_stages.feature b/tests/features/interlock_stages.feature index b7dd6b3..793f550 100644 --- a/tests/features/interlock_stages.feature +++ b/tests/features/interlock_stages.feature @@ -70,6 +70,25 @@ Feature: interlocks stage commands on a minimal inline project And the stage output contains "changed vs HEAD" And the stage output contains "skipped under --changed" + # req: stage-check + Scenario: `interlocks check --changed` skips each graph-wide gate with a named reason + Given a minimal tmp project initialized as a git repo + And the tmp project has a changed Python file + When I run "interlocks check --changed=HEAD" in the tmp project + Then the stage exits 0 + And the stage output contains "test: skipped under --changed" + And the stage output contains "deps: skipped under --changed" + And the stage output contains "attribution: skipped under --changed" + + # req: stage-check + Scenario: `interlocks check --changed` runs file-level gates on changed Python files + Given a minimal tmp project initialized as a git repo + And the tmp project has a changed Python file + When I run "interlocks check --changed=HEAD" in the tmp project + Then the stage exits 0 + And the stage output contains "[fix]" + And the stage output contains "[typecheck]" + # req: stage-check Scenario: `interlocks check --changed` honors changed_ref from pyproject Given a minimal tmp project initialized as a git repo diff --git a/tests/step_defs/test_interlock_stages.py b/tests/step_defs/test_interlock_stages.py index e0de514..7399d76 100644 --- a/tests/step_defs/test_interlock_stages.py +++ b/tests/step_defs/test_interlock_stages.py @@ -138,7 +138,11 @@ def _stage_exits(stage_result: subprocess.CompletedProcess[str], code: int) -> N ) +def _stage_combined(result: subprocess.CompletedProcess[str]) -> str: + return result.stdout + result.stderr + + @then(parsers.parse('the stage output contains "{fragment}"')) def _stage_output_contains(stage_result: subprocess.CompletedProcess[str], fragment: str) -> None: - combined = stage_result.stdout + stage_result.stderr + combined = _stage_combined(stage_result) assert fragment in combined, f"expected {fragment!r} in stage output; got:\n{combined}" diff --git a/tests/tasks/test_arch.py b/tests/tasks/test_arch.py index fd1d852..e9833ed 100644 --- a/tests/tasks/test_arch.py +++ b/tests/tasks/test_arch.py @@ -327,7 +327,7 @@ def test_task_arch_uses_import_linter_pin_override( version = "0.0.0" [tool.importlinter] - root_package = archpin + root_package = "archpin" [tool.interlocks.tools] import-linter = "{custom_pin}"