From 85dd582ce7585a0d3d2ffe7d735097258d49cd1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 12:32:51 +0200 Subject: [PATCH 1/2] test(ci): probe evidence payload bounds, context gating, and custom path Adds Unit 10 CI evidence invariant tests: - `test_ci_evidence_payload_is_bounded`: asserts the JSON written by `_write_ci_evidence` contains exactly the five allowed keys (command, elapsed_seconds, created_at, passed, budget_seconds); no extra fields. - `test_ci_evidence_context_key_present_only_when_provided`: verifies `context` key appears only when the env-sourced argument is non-None. - `test_ci_evidence_uses_custom_ci_evidence_path`: drives `cmd_ci()` in-process with task stubs and asserts evidence lands at the `[tool.interlocks] ci_evidence_path` override. Also adds two `test_config.py` probes for `ci_evidence_path` resolution: default resolves under project root; override resolves relative to root. All imports lifted to module level; `_make_minimal_project` extended with optional `name`/`tool_interlocks` params to eliminate setup duplication in the custom-path test. --- tests/stages/test_ci.py | 92 +++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 29 +++++++++++++ 2 files changed, 121 insertions(+) diff --git a/tests/stages/test_ci.py b/tests/stages/test_ci.py index b307c1d..4d0be54 100644 --- a/tests/stages/test_ci.py +++ b/tests/stages/test_ci.py @@ -10,6 +10,8 @@ import pytest +from interlocks.config import load_config +from interlocks.stages.ci import _write_ci_evidence from tests.conftest import TmpProjectFactory _PYPROJECT = textwrap.dedent( @@ -500,3 +502,93 @@ def test_ci_skips_acceptance_when_optional_missing( assert "Acceptance (required)" not in descriptions assert "Acceptance (pytest-bdd)" not in descriptions + + +# ─────────────── CI evidence invariants ───────────────────────────── + + +def _make_minimal_project( + tmp_path: Path, + *, + name: str = "ci-evidence-probe", + tool_interlocks: str = "", +) -> None: + (tmp_path / "tests").mkdir() + body = f"[project]\nname = {name!r}\nversion = '0.0.0'\n" + if tool_interlocks: + body += f"\n[tool.interlocks]\n{tool_interlocks}\n" + (tmp_path / "pyproject.toml").write_text(body, encoding="utf-8") + + +def test_ci_evidence_payload_is_bounded( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Evidence file contains exactly the bounded, non-sensitive key set; no extra fields.""" + _make_minimal_project(tmp_path) + monkeypatch.chdir(tmp_path) + + cfg = load_config() + _write_ci_evidence(cfg, elapsed_seconds=2.5, passed=True, context=None) + + data = json.loads((tmp_path / ".interlocks" / "ci.json").read_text(encoding="utf-8")) + assert set(data.keys()) == { + "command", + "elapsed_seconds", + "created_at", + "passed", + "budget_seconds", + } + assert data["command"] == "interlocks ci" + assert isinstance(data["elapsed_seconds"], float) + assert isinstance(data["created_at"], float) + assert data["passed"] is True + assert isinstance(data["budget_seconds"], int) + + +def test_ci_evidence_context_key_present_only_when_provided( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """context key appears iff context arg is passed; absent when arg is None.""" + _make_minimal_project(tmp_path) + monkeypatch.chdir(tmp_path) + + cfg = load_config() + evidence = tmp_path / ".interlocks" / "ci.json" + + _write_ci_evidence(cfg, elapsed_seconds=1.0, passed=True, context="pr_push") + data = json.loads(evidence.read_text(encoding="utf-8")) + assert data["context"] == "pr_push" + + _write_ci_evidence(cfg, elapsed_seconds=1.0, passed=False, context=None) + data = json.loads(evidence.read_text(encoding="utf-8")) + assert "context" not in data + + +def test_ci_evidence_uses_custom_ci_evidence_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """ci_evidence_path from [tool.interlocks] is used when cmd_ci runs in-process.""" + _make_minimal_project( + tmp_path, + name="ci-custom-path", + tool_interlocks='ci_evidence_path = "reports/timing.json"', + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["interlocks", "ci"]) + + from interlocks.stages import ci as ci_mod + + monkeypatch.setattr(ci_mod, "run_tasks", lambda tasks: None) + monkeypatch.setattr(ci_mod, "cmd_crap", lambda: None) + monkeypatch.setattr(ci_mod, "cmd_behavior_attribution", lambda refresh=False: None) + monkeypatch.setattr(ci_mod, "cmd_mutation", lambda **_kw: None) + + ci_mod.cmd_ci() + + custom_path = tmp_path / "reports" / "timing.json" + assert custom_path.exists(), f"evidence not written to custom path {custom_path}" + data = json.loads(custom_path.read_text(encoding="utf-8")) + assert data["command"] == "interlocks ci" diff --git a/tests/test_config.py b/tests/test_config.py index d7c57d3..e42e744 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -822,3 +822,32 @@ def test_invoker_prefix_uv_ignores_target_venv(tmp_path: Path) -> None: _make_stub_venv_python(tmp_path) cfg = _cfg(project_root=tmp_path, test_invoker="uv") assert invoker_prefix(cfg) == ["uv", "run", "python", "-m"] + + +# ─────────────── ci_evidence_path ─────────────────────────────────── + + +def test_ci_evidence_path_default_resolves_under_project_root(tmp_project: Path) -> None: + """Default ci_evidence_path is .interlocks/ci.json relative to project root.""" + cfg = load_config() + assert cfg.ci_evidence_path == (tmp_project / ".interlocks" / "ci.json").resolve() + + +def test_ci_evidence_path_override_resolves_from_project_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ci_evidence_path in [tool.interlocks] resolves relative to project root.""" + _setup_pyproject( + tmp_path, + monkeypatch, + """ + [project] + name = "custom-evidence" + version = "0.0.0" + + [tool.interlocks] + ci_evidence_path = "reports/timing.json" + """, + ) + cfg = load_config() + assert cfg.ci_evidence_path == (tmp_path / "reports" / "timing.json").resolve() From ed878d623b860df8220f15463a9ecc40d875b018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 12:43:27 +0200 Subject: [PATCH 2/2] refactor(test): use cfg.ci_evidence_path instead of hardcoded path literals Address gemini-code-assist review: replace hardcoded `.interlocks/ci.json` path strings with `cfg.ci_evidence_path` (instance property) and `InterlockConfig.ci_evidence_path` (class-level default) so tests stay robust when the default configuration changes. --- tests/stages/test_ci.py | 4 ++-- tests/test_config.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/stages/test_ci.py b/tests/stages/test_ci.py index 4d0be54..982fac9 100644 --- a/tests/stages/test_ci.py +++ b/tests/stages/test_ci.py @@ -531,7 +531,7 @@ def test_ci_evidence_payload_is_bounded( cfg = load_config() _write_ci_evidence(cfg, elapsed_seconds=2.5, passed=True, context=None) - data = json.loads((tmp_path / ".interlocks" / "ci.json").read_text(encoding="utf-8")) + data = json.loads(cfg.ci_evidence_path.read_text(encoding="utf-8")) assert set(data.keys()) == { "command", "elapsed_seconds", @@ -555,7 +555,7 @@ def test_ci_evidence_context_key_present_only_when_provided( monkeypatch.chdir(tmp_path) cfg = load_config() - evidence = tmp_path / ".interlocks" / "ci.json" + evidence = cfg.ci_evidence_path _write_ci_evidence(cfg, elapsed_seconds=1.0, passed=True, context="pr_push") data = json.loads(evidence.read_text(encoding="utf-8")) diff --git a/tests/test_config.py b/tests/test_config.py index e42e744..1d14571 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -830,7 +830,7 @@ def test_invoker_prefix_uv_ignores_target_venv(tmp_path: Path) -> None: def test_ci_evidence_path_default_resolves_under_project_root(tmp_project: Path) -> None: """Default ci_evidence_path is .interlocks/ci.json relative to project root.""" cfg = load_config() - assert cfg.ci_evidence_path == (tmp_project / ".interlocks" / "ci.json").resolve() + assert cfg.ci_evidence_path == (tmp_project / InterlockConfig.ci_evidence_path).resolve() def test_ci_evidence_path_override_resolves_from_project_root(