Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment on lines +46 to 50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize the threshold-based all-files hook

When pre-commit run --all-files is used, this hook may be invoked in multiple parallel filename batches because require_serial is not enabled; pre-commit defines that option as executing the hook using a single process. Each process therefore evaluates the 40-file threshold independently: with the repository's 366 Python files, sufficiently large batches each launch the same historical pytest subset, while smaller batches take the selective path instead. This can run the suite repeatedly, exceed the CI job's 15-minute limit, or fail to preserve the promised historical subset; add require_serial: true or detect all-files mode independently of per-invocation arguments.

Useful? React with 👍 / 👎.


# Security: Block banned imports in engine/
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/test_precommit_pytest_unit.py
Original file line number Diff line number Diff line change
@@ -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
143 changes: 143 additions & 0 deletions tools/run_precommit_pytest_unit.py
Original file line number Diff line number Diff line change
@@ -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",
]
Comment on lines +67 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include unit tests whose names extend the implementation stem

For a commit changing only engine/graph/driver.py, these candidates and the later suffix search return no tests, so main() exits successfully without running pytest even though tests/unit/test_graph_driver_database_binding.py, test_outcomes.py, and test_wave4_state_resilience.py import and exercise GraphDriver. The same mismatch occurs for several feature-grouped modules, so common engine changes silently bypass the intended pre-commit unit coverage; use a reverse-import/declarative mapping or a safe fallback when exact filename inference finds nothing.

AGENTS.md reference: AGENTS.md:L34-L42

Useful? React with 👍 / 👎.

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())
Loading