From 4276193c6a035f296117885105fb9b7c0a1f172c 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 1/2] 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 df2e818c9caae1d647ff1f3d39537123f71c9b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 12:43:04 +0200 Subject: [PATCH 2/2] fix(test): quote TOML string value root_package in arch pin test Unquoted bare word caused tomllib.TOMLDecodeError when test parsed the inline pyproject.toml fixture. Addresses Gemini review comment. --- tests/tasks/test_arch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}"