"""AC-4 — components MUST NOT call ``time.monotonic_ns`` / ``time.time_ns`` / ``time.sleep``. Enforces Invariant 2 of the replay contract (``_docs/02_document/contracts/replay/replay_protocol.md``): every time-driven code path in a C* component consumes an injected :class:`Clock` instead. Replay determinism (R-DEMO-4) collapses the moment a component reaches into the stdlib ``time`` module directly, so this guard runs on every PR touching ``src/gps_denied_onboard/components/``. The scan is AST-based — docstrings and comments mentioning the forbidden APIs do NOT trip it; only call sites and attribute references do. """ from __future__ import annotations import ast from pathlib import Path import pytest _FORBIDDEN_ATTRS: frozenset[str] = frozenset( {"monotonic_ns", "time_ns", "sleep"} ) _COMPONENTS_ROOT: Path = ( Path(__file__).parent.parent.parent / "src" / "gps_denied_onboard" / "components" ) def _python_files_under(root: Path) -> list[Path]: return sorted(p for p in root.rglob("*.py") if p.is_file()) def _find_direct_time_references(source: str) -> list[tuple[int, str]]: """Return ``(lineno, attribute_name)`` for every direct ``time.X`` ref. Only flags ``ast.Attribute(value=ast.Name(id='time'), attr=)`` where ```` is one of the forbidden names. Aliased imports (``import time as t`` → ``t.monotonic_ns()``) are intentionally NOT caught — the component code convention is to avoid such aliases, and catching them would require flow-sensitive analysis. """ hits: list[tuple[int, str]] = [] tree = ast.parse(source) for node in ast.walk(tree): if not isinstance(node, ast.Attribute): continue if not isinstance(node.value, ast.Name): continue if node.value.id != "time": continue if node.attr in _FORBIDDEN_ATTRS: hits.append((node.lineno, f"time.{node.attr}")) return hits def test_components_have_no_direct_time_references() -> None: # Arrange files = _python_files_under(_COMPONENTS_ROOT) assert files, f"AST scan found no .py files under {_COMPONENTS_ROOT}" offences: list[str] = [] # Act for file in files: source = file.read_text(encoding="utf-8") for lineno, attr in _find_direct_time_references(source): rel = file.relative_to(_COMPONENTS_ROOT.parent.parent.parent.parent) offences.append(f"{rel}:{lineno} — {attr}") # Assert assert not offences, ( "Invariant 2 violation: direct stdlib-`time` references found in " "`src/gps_denied_onboard/components/**/*.py`. Consume an injected " "`Clock` (`gps_denied_onboard.clock`) instead.\n" + "\n".join(offences) ) def test_scan_helper_detects_known_forbidden_pattern() -> None: # Arrange — self-check the AST helper so a stale scan can't silently pass. source = "import time\ndef f() -> int:\n return time.monotonic_ns()\n" # Act hits = _find_direct_time_references(source) # Assert assert hits == [(3, "time.monotonic_ns")] def test_scan_helper_ignores_docstring_mentions() -> None: # Arrange — docstrings naming the forbidden API must not trip the scan. source = '"""This module talks about time.monotonic_ns in prose only."""\n' # Act hits = _find_direct_time_references(source) # Assert assert hits == [] @pytest.mark.parametrize("forbidden", sorted(_FORBIDDEN_ATTRS)) def test_scan_helper_detects_each_forbidden_attr(forbidden: str) -> None: # Arrange source = f"import time\ntime.{forbidden}()\n" # Act hits = _find_direct_time_references(source) # Assert assert hits == [(2, f"time.{forbidden}")]