"""Contract tests for scripts/ci/classify_changes.py. Each case asserts the *relationship* between a changed-file set and the lanes that must run — the safety contract of the gating, not a snapshot. Governing invariant: fail open. We may run a lane we didn't need, never skip one a change could have broken. """ from __future__ import annotations import importlib.util from pathlib import Path import pytest _PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "classify_changes.py" _spec = importlib.util.spec_from_file_location("classify_changes", _PATH) _mod = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_mod) classify = _mod.classify ALL = {"python": True, "frontend": True, "site": True} def _lanes(python=False, frontend=False, site=False) -> dict[str, bool]: return {"python": python, "frontend": frontend, "site": site} CASES = { "docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()), "python source → python": (["run_agent.py"], _lanes(python=True)), "dep manifest → python": (["pyproject.toml"], _lanes(python=True)), "uv.lock → python": (["uv.lock"], _lanes(python=True)), "ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)), "ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)), # Lockfile bump shifts every TS package's tree, but not the Python suite. "root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True)), "website → site": (["website/docs/intro.md"], _lanes(site=True)), # SKILL.md reads like docs, but the skill-doc tests read skills/, so a # skill edit must still run Python. "skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)), # Unknown top-level file keeps Python on rather than risk a silent skip. "unknown toplevel → python": (["Makefile"], _lanes(python=True)), "mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True)), "mixed docs+frontend → frontend": (["README.md", "apps/x.tsx"], _lanes(frontend=True)), # Fail open: CI-config / empty / blank diffs run everything. ".github change → all": ([".github/workflows/tests.yml"], ALL), "action change → all": ([".github/actions/detect-changes/action.yml"], ALL), "empty diff → all": ([], ALL), "blank lines → all": (["", " "], ALL), } @pytest.mark.parametrize("files,expected", CASES.values(), ids=CASES.keys()) def test_classify(files, expected): assert classify(files) == expected