Coverage for little_loops / hooks / learning_tests_gate.py: 0%
88 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""Learning-test discoverability gate for PreToolUse (FEAT-1742).
3When the host is about to execute a Write or Edit tool call, parses the
4file content for external package imports, queries the Learning Test
5Registry for each package, and emits a nudge when an import has no
6proven record and discoverability is enabled.
8Config keys read from ``.ll/ll-config.json``:
9- ``learning_tests.enabled`` (bool, default False) — master switch.
10- ``learning_tests.discoverability.mode`` (str, default "warn") —
11 ``"off"`` silences all hints; ``"warn"`` emits a hint and allows the
12 tool call; ``"block"`` injects feedback into model context and blocks.
13- ``learning_tests.discoverability.skip_packages`` (list[str]) —
14 packages that are never flagged (stdlib, type stubs, etc.).
15"""
17from __future__ import annotations
19import json
20import re
21from pathlib import Path
23from little_loops.config.core import resolve_config_path
24from little_loops.config.features import LearningTestsConfig
25from little_loops.hooks.types import LLHookEvent, LLHookResult
26from little_loops.learning_tests import check_learning_test
28_PY_IMPORT_RE = re.compile(r"^(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE)
29_JS_REQUIRE_RE = re.compile(r"""require\s*\(\s*['"]([^./'"][^'"]*)['"]\s*\)""")
30_JS_IMPORT_RE = re.compile(
31 r"""(?:^|\n)\s*import\s+(?:.*?\s+from\s+)?['"]([^./'"][^'"]+)['"]""", re.MULTILINE
32)
34_BUILTIN_SKIP: frozenset[str] = frozenset(
35 {"__future__", "__builtins__", "typing_extensions", "abc", "io", "re", "json"}
36)
38# Session-level cache: package name → True (proven) / False (no record or refuted/stale).
39# Avoids repeated registry lookups for the same package within a session.
40_SESSION_CACHE: dict[str, bool] = {}
43def _load_lt_config(cwd: Path) -> LearningTestsConfig:
44 """Load LearningTestsConfig from project config, returning defaults on miss."""
45 config_path = resolve_config_path(cwd)
46 if config_path is None:
47 return LearningTestsConfig()
48 try:
49 data = json.loads(config_path.read_text(encoding="utf-8"))
50 except (OSError, json.JSONDecodeError):
51 return LearningTestsConfig()
52 if not isinstance(data, dict):
53 return LearningTestsConfig()
54 return LearningTestsConfig.from_dict(data.get("learning_tests", {}))
57def _extract_packages(content: str, file_path: str) -> list[str]:
58 """Extract unique top-level package names from file content."""
59 seen: set[str] = set()
60 pkgs: list[str] = []
61 is_js = file_path.endswith((".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"))
63 if is_js:
64 candidates: list[str] = []
65 for m in _JS_REQUIRE_RE.finditer(content):
66 candidates.append(m.group(1))
67 for m in _JS_IMPORT_RE.finditer(content):
68 candidates.append(m.group(1))
69 for raw in candidates:
70 pkg = raw if raw.startswith("@") else raw.split("/")[0]
71 if pkg and pkg not in seen:
72 seen.add(pkg)
73 pkgs.append(pkg)
74 else:
75 for m in _PY_IMPORT_RE.finditer(content):
76 pkg = m.group(1)
77 if pkg not in seen:
78 seen.add(pkg)
79 pkgs.append(pkg)
81 return pkgs
84def gate(event: LLHookEvent) -> LLHookResult:
85 """Check file imports against the Learning Test Registry and nudge on gaps."""
86 cwd = Path(event.cwd) if event.cwd else Path.cwd()
87 lt_config = _load_lt_config(cwd)
89 if not lt_config.enabled:
90 return LLHookResult(exit_code=0)
92 disc = lt_config.discoverability
93 if disc.mode == "off":
94 return LLHookResult(exit_code=0)
96 payload = event.payload
97 tool_name = payload.get("tool_name", "")
98 tool_input: dict = payload.get("tool_input", {}) or {}
100 if tool_name == "Write":
101 content = tool_input.get("content", "") or ""
102 file_path = tool_input.get("file_path", "") or ""
103 elif tool_name == "Edit":
104 content = tool_input.get("new_string", "") or ""
105 file_path = tool_input.get("file_path", "") or ""
106 else:
107 return LLHookResult(exit_code=0)
109 if not content:
110 return LLHookResult(exit_code=0)
112 skip = set(disc.skip_packages) | _BUILTIN_SKIP
113 packages = _extract_packages(content, file_path)
114 base_dir = cwd / ".ll" / "learning-tests"
116 missing: list[str] = []
117 for pkg in packages:
118 if pkg in skip:
119 continue
120 if pkg in _SESSION_CACHE:
121 if not _SESSION_CACHE[pkg]:
122 missing.append(pkg)
123 continue
124 record = check_learning_test(pkg, base_dir=base_dir)
125 proven = record is not None and record.status == "proven"
126 _SESSION_CACHE[pkg] = proven
127 if not proven:
128 missing.append(pkg)
130 if not missing:
131 return LLHookResult(exit_code=0)
133 pkg_list = ", ".join(f'"{p}"' for p in missing)
134 hint = (
135 f"[ll: proof-first hint] No learning-test record found for {pkg_list}. "
136 f"You're about to write integration code based on training-data assumptions. "
137 f'Consider: ll-loop run proof-first-task --context task="<your task>"'
138 )
140 if disc.mode == "block":
141 return LLHookResult(exit_code=2, feedback=hint)
142 return LLHookResult(exit_code=0, feedback=hint)