Coverage for little_loops / learning_tests / gate.py: 27%
37 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""Shared gate utilities for learning-test staleness checks (ENH-2208).
3Exposes ``is_record_stale()`` and ``format_nudge_message()`` as standalone
4importable helpers consumed by the discoverability gate hook, the install
5learning gate hook (ENH-2212), and downstream sprint/release gates
6(ENH-2209, ENH-2210, ENH-2214, ENH-2217).
8Also exposes ``run_learning_gate_for_issue()`` — the shared subprocess wrapper
9for the ``proof-first-task`` loop used by ll-auto (ENH-2319).
10"""
12from __future__ import annotations
14import datetime
15import json
16import subprocess
17from pathlib import Path
18from typing import TYPE_CHECKING, Literal
20if TYPE_CHECKING:
21 from little_loops.learning_tests import LearnTestRecord
24def format_nudge_message(pkg: str, stale: bool = False) -> str:
25 """Return a nudge message for a package with no proven or stale learning test.
27 Args:
28 pkg: The package name (already normalized).
29 stale: If True, the record exists but is stale; otherwise it is absent.
30 """
31 if stale:
32 body = f'Learning test for "{pkg}" is stale.'
33 else:
34 body = f'No learning test for "{pkg}".'
35 return f'[ll: new dependency] {body} Consider: /ll:explore-api "{pkg}"'
38def is_record_stale(record: LearnTestRecord, stale_after_days: int) -> bool:
39 """Return True if the record's proof date exceeds the staleness threshold.
41 Args:
42 record: A LearnTestRecord with a ``date`` field (ISO 8601: YYYY-MM-DD).
43 stale_after_days: Age threshold in days. Clamped to minimum 1 to
44 prevent ``stale_after_days=0`` from being a footgun that passes
45 all records whose date is exactly today.
47 Returns:
48 True if age in days exceeds the threshold; False if fresh or unparseable.
49 """
50 threshold = max(1, stale_after_days)
51 try:
52 record_date = datetime.date.fromisoformat(record.date)
53 except (ValueError, TypeError, AttributeError):
54 return False
55 age_days = (datetime.date.today() - record_date).days
56 return age_days > threshold
59def _read_loop_final_state(cwd: Path, loop_name: str) -> str | None:
60 """Read the terminal state from a completed loop's running state file.
62 After ``ll-loop run`` exits, the state file remains at
63 ``<cwd>/.loops/.running/<loop_name>.state.json``. The ``current_state``
64 field holds the terminal state name (e.g. ``"done"``, ``"blocked"``).
65 """
66 state_file = cwd / ".loops" / ".running" / f"{loop_name}.state.json"
67 if not state_file.exists():
68 return None
69 try:
70 data = json.loads(state_file.read_text())
71 return data.get("current_state")
72 except (json.JSONDecodeError, OSError):
73 return None
76def run_learning_gate_for_issue(
77 issue_path: Path,
78 *,
79 skip: bool = False,
80 cwd: Path | None = None,
81) -> Literal["passed", "blocked", "skipped"]:
82 """Invoke proof-first-task loop for an issue and return the gate verdict.
84 ``skip=True`` short-circuits to "skipped" (honours --skip-learning-gate).
85 All terminal states (done, blocked, impl_failed) exit 0 — blocked is
86 distinguished from done by reading the loop state file left after execution.
88 Args:
89 issue_path: Absolute path to the issue file.
90 skip: If True, return "skipped" immediately without running the loop.
91 cwd: Working directory for the subprocess (and state-file lookup).
92 Defaults to ``Path.cwd()`` when None.
93 """
94 if skip:
95 return "skipped"
97 working_dir = cwd or Path.cwd()
98 subprocess.run(
99 [
100 "ll-loop",
101 "run",
102 "proof-first-task",
103 "--context",
104 f"issue_file={issue_path}",
105 ],
106 capture_output=True,
107 text=True,
108 cwd=working_dir,
109 )
111 final_state = _read_loop_final_state(working_dir, "proof-first-task")
112 if final_state == "blocked":
113 return "blocked"
114 return "passed"