From 6c3723bd13d6a2fa5e37e4c462555f4e3ba37d84 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Fri, 21 Aug 2026 16:10:17 -0400 Subject: [PATCH] fix(ci): run pre-commit pytest-unit on staged files only Stop always-running the unit catalog on every commit. Infer tests from changed Python; leave make test and CI pytest as the full suite. Co-authored-by: Cursor --- .pre-commit-config.yaml | 12 +- tests/unit/test_precommit_pytest_unit.py | 59 ++++++++++ tools/run_precommit_pytest_unit.py | 143 +++++++++++++++++++++++ 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_precommit_pytest_unit.py create mode 100644 tools/run_precommit_pytest_unit.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b14867..a5acb3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,16 +37,16 @@ repos: additional_dependencies: [pydantic, pydantic-settings, types-PyYAML, neo4j, structlog] exclude: ^(tests/|tools/|domains/|current work/|harvest_staging/|docs/|engine/security/|engine/auth/|agents/cursor/) - # Unit tests (fast subset for pre-commit) - # Note: Some tests require mock updates; run full suite with `pytest tests/` + # Unit tests covering staged Python only. Full catalog: make test / CI pytest. + # pre-commit run --all-files (≥40 .py files) keeps the historical unit subset. - repo: local hooks: - id: pytest-unit - name: pytest unit tests - entry: bash -c 'PYTHONPATH="${PYTHONPATH}:." python3 -m pytest tests/ -m "unit" --ignore=tests/unit/test_gates_all_types.py --ignore=tests/unit/test_scoring.py --ignore=tests/unit/test_config.py --ignore=tests/unit/test_arbitration.py --ignore=tests/unit/test_wave6_dormant_features.py --tb=short -q' + name: pytest unit tests (changed files) + entry: python3 tools/run_precommit_pytest_unit.py language: system - pass_filenames: false - always_run: true + types: [python] + pass_filenames: true stages: [pre-commit] # Security: Block banned imports in engine/ diff --git a/tests/unit/test_precommit_pytest_unit.py b/tests/unit/test_precommit_pytest_unit.py new file mode 100644 index 0000000..8456288 --- /dev/null +++ b/tests/unit/test_precommit_pytest_unit.py @@ -0,0 +1,59 @@ +""" +--- L9_META --- +l9_schema: 1 +origin: engine-specific +engine: graph +layer: [test] +tags: [precommit, pytest, unit] +owner: platform +status: active +--- /L9_META --- + +Unit tests for tools/run_precommit_pytest_unit.py path selection. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools")) + +from run_precommit_pytest_unit import infer_unit_tests, select_unit_tests # noqa: E402 + + +@pytest.mark.unit +def test_compiler_change_selects_gate_compiler_not_catalog() -> None: + selected = select_unit_tests(["engine/gates/compiler.py"], REPO) + assert "tests/unit/test_gate_compiler.py" in selected + assert "tests/unit/test_payload_contract_compiler.py" not in selected + assert "tests/unit/test_gates_all_types.py" not in selected + assert "tests/" not in selected + + +@pytest.mark.unit +def test_staged_slow_file_is_allowed() -> None: + selected = select_unit_tests(["tests/unit/test_scoring.py"], REPO) + assert selected == ["tests/unit/test_scoring.py"] + + +@pytest.mark.unit +def test_integration_change_is_ignored() -> None: + selected = select_unit_tests(["tests/integration/test_pipeline.py"], REPO) + assert selected == [] + + +@pytest.mark.unit +def test_unknown_impl_does_not_dump_unit_dir() -> None: + selected = select_unit_tests(["engine/does_not_exist_zzzz.py"], REPO) + assert selected == [] + assert "tests/unit" not in selected + + +@pytest.mark.unit +def test_infer_parent_stem_mapping() -> None: + hits = infer_unit_tests("engine/gates/compiler.py", REPO) + assert "tests/unit/test_gate_compiler.py" in hits diff --git a/tools/run_precommit_pytest_unit.py b/tools/run_precommit_pytest_unit.py new file mode 100644 index 0000000..2fda5ec --- /dev/null +++ b/tools/run_precommit_pytest_unit.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +--- L9_META --- +l9_schema: 1 +origin: engine-specific +engine: graph +layer: [tools, governance] +tags: [precommit, pytest, unit] +owner: platform +status: active +--- /L9_META --- + +Select unit tests for the local pytest-unit pre-commit hook. + +Commits receive only tests that cover the staged Python files. The full +catalog stays on `make test` / CI pytest. `pre-commit run --all-files` +(CI hook job) still runs the historical unit subset when many files arrive. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +SLOW_UNLESS_STAGED: frozenset[str] = frozenset( + { + "tests/unit/test_gates_all_types.py", + "tests/unit/test_scoring.py", + "tests/unit/test_config.py", + "tests/unit/test_arbitration.py", + "tests/unit/test_wave6_dormant_features.py", + } +) + +NON_UNIT_PREFIXES: tuple[str, ...] = ( + "tests/integration/", + "tests/compliance/", + "tests/e2e/", + "tests/performance/", + "tests/invariants/", +) + +ALL_FILES_THRESHOLD = 40 + +LEGACY_ARGV: tuple[str, ...] = ( + "-m", + "unit", + "--ignore=tests/unit/test_gates_all_types.py", + "--ignore=tests/unit/test_scoring.py", + "--ignore=tests/unit/test_config.py", + "--ignore=tests/unit/test_arbitration.py", + "--ignore=tests/unit/test_wave6_dormant_features.py", +) + + +def _norm(path: str) -> str: + return path.strip().replace("\\", "/").lstrip("./") + + +def infer_unit_tests(impl: str, repo: Path) -> list[str]: + stem = Path(impl).stem + parent = Path(impl).parent.name + candidates = [ + f"tests/unit/test_{stem}.py", + f"tests/unit/test_{parent}_{stem}.py", + f"tests/unit/test_{parent}.py", + ] + found = [item for item in candidates if (repo / item).is_file()] + if found: + return found + unit_dir = repo / "tests" / "unit" + if not unit_dir.is_dir(): + return [] + prefix = "test_" + suffix = f"_{stem}.py" + hits: list[str] = [] + for path in unit_dir.glob(f"test_*_{stem}.py"): + if not path.is_file(): + continue + mid = path.name[len(prefix) : -len(suffix)] + if mid and "_" not in mid: + hits.append(path.relative_to(repo).as_posix()) + return sorted(hits)[:8] + + +def select_unit_tests(changed: list[str], repo: Path) -> list[str]: + py_files = [_norm(path) for path in changed if _norm(path).endswith(".py")] + selected: list[str] = [] + for path in py_files: + if any(path.startswith(prefix) for prefix in NON_UNIT_PREFIXES): + continue + if path.startswith("tests/"): + selected.append(path) + continue + selected.extend(infer_unit_tests(path, repo)) + + staged = set(py_files) + unique: list[str] = [] + seen: set[str] = set() + for item in selected: + if item in seen: + continue + if item in SLOW_UNLESS_STAGED and item not in staged: + continue + if not (repo / item).is_file(): + continue + seen.add(item) + unique.append(item) + return unique + + +def _run_pytest(args: list[str]) -> int: + env = os.environ.copy() + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = f"{REPO_ROOT}{os.pathsep}{existing}" if existing else str(REPO_ROOT) + cmd = [sys.executable, "-m", "pytest", "--tb=short", "-q", *args] + print("pytest-unit:", " ".join(cmd[3:])) + completed = subprocess.run(cmd, cwd=REPO_ROOT, env=env, check=False) + return completed.returncode + + +def main(argv: list[str] | None = None) -> int: + files = [_norm(item) for item in (argv if argv is not None else sys.argv[1:])] + py_files = [item for item in files if item.endswith(".py")] + if not py_files: + print("OK: no Python files staged; skip pytest-unit (full catalog is make test)") + return 0 + if len(py_files) >= ALL_FILES_THRESHOLD: + print("OK: many files — historical unit subset (CI --all-files)") + return _run_pytest(["tests/", *LEGACY_ARGV]) + selected = select_unit_tests(py_files, REPO_ROOT) + if not selected: + print("OK: no unit tests inferred for staged Python; full catalog is make test") + return 0 + return _run_pytest(selected) + + +if __name__ == "__main__": + raise SystemExit(main())