Coverage for little_loops / hooks / learning_tests_gate.py: 0%
106 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"""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 datetime
20import json
21import re
22from pathlib import Path
24from little_loops.config.core import resolve_config_path
25from little_loops.config.features import LearningTestsConfig
26from little_loops.hooks.types import LLHookEvent, LLHookResult
27from little_loops.learning_tests import check_learning_test
28from little_loops.learning_tests.gate import is_record_stale
29from little_loops.learning_tests.import_scan import _PY_IMPORT_RE
31_JS_REQUIRE_RE = re.compile(r"""require\s*\(\s*['"]([^./'"][^'"]*)['"]\s*\)""")
32_JS_IMPORT_RE = re.compile(
33 r"""(?:^|\n)\s*import\s+(?:.*?\s+from\s+)?['"]([^./'"][^'"]+)['"]""", re.MULTILINE
34)
36_BUILTIN_SKIP: frozenset[str] = frozenset(
37 {"__future__", "__builtins__", "typing_extensions", "abc", "io", "re", "json"}
38)
40# Session-level cache: package name → True (proven) / False (no record or refuted/stale).
41# Avoids repeated registry lookups for the same package within a session.
42_SESSION_CACHE: dict[str, bool] = {}
45def _load_lt_config(cwd: Path) -> LearningTestsConfig:
46 """Load LearningTestsConfig from project config, returning defaults on miss."""
47 config_path = resolve_config_path(cwd)
48 if config_path is None:
49 return LearningTestsConfig()
50 try:
51 data = json.loads(config_path.read_text(encoding="utf-8"))
52 except (OSError, json.JSONDecodeError):
53 return LearningTestsConfig()
54 if not isinstance(data, dict):
55 return LearningTestsConfig()
56 return LearningTestsConfig.from_dict(data.get("learning_tests", {}))
59def _extract_packages(content: str, file_path: str) -> list[str]:
60 """Extract unique top-level package names from file content."""
61 seen: set[str] = set()
62 pkgs: list[str] = []
63 is_js = file_path.endswith((".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"))
65 if is_js:
66 candidates: list[str] = []
67 for m in _JS_REQUIRE_RE.finditer(content):
68 candidates.append(m.group(1))
69 for m in _JS_IMPORT_RE.finditer(content):
70 candidates.append(m.group(1))
71 for raw in candidates:
72 pkg = raw if raw.startswith("@") else raw.split("/")[0]
73 if pkg and pkg not in seen:
74 seen.add(pkg)
75 pkgs.append(pkg)
76 else:
77 for m in _PY_IMPORT_RE.finditer(content):
78 pkg = m.group(1)
79 if pkg not in seen:
80 seen.add(pkg)
81 pkgs.append(pkg)
83 return pkgs
86def gate(event: LLHookEvent) -> LLHookResult:
87 """Check file imports against the Learning Test Registry and nudge on gaps."""
88 cwd = Path(event.cwd) if event.cwd else Path.cwd()
89 lt_config = _load_lt_config(cwd)
91 if not lt_config.enabled:
92 return LLHookResult(exit_code=0)
94 disc = lt_config.discoverability
95 if disc.mode == "off":
96 return LLHookResult(exit_code=0)
98 payload = event.payload
99 tool_name = payload.get("tool_name", "")
100 tool_input: dict = payload.get("tool_input", {}) or {}
102 if tool_name == "Write":
103 content = tool_input.get("content", "") or ""
104 file_path = tool_input.get("file_path", "") or ""
105 elif tool_name == "Edit":
106 content = tool_input.get("new_string", "") or ""
107 file_path = tool_input.get("file_path", "") or ""
108 else:
109 return LLHookResult(exit_code=0)
111 if not content:
112 return LLHookResult(exit_code=0)
114 skip = set(disc.skip_packages) | _BUILTIN_SKIP
115 packages = _extract_packages(content, file_path)
116 base_dir = cwd / ".ll" / "learning-tests"
118 missing: list[str] = []
119 stale_ages: dict[str, int] = {} # pkg -> age in days, for stale proven records
120 for pkg in packages:
121 if pkg in skip:
122 continue
123 if pkg in _SESSION_CACHE:
124 if not _SESSION_CACHE[pkg]:
125 missing.append(pkg)
126 continue
127 record = check_learning_test(pkg, base_dir=base_dir)
128 if record is not None and record.status == "proven":
129 if is_record_stale(record, lt_config.stale_after_days):
130 _SESSION_CACHE[pkg] = False
131 try:
132 age = (datetime.date.today() - datetime.date.fromisoformat(record.date)).days
133 stale_ages[pkg] = age
134 except (ValueError, TypeError):
135 pass
136 missing.append(pkg)
137 else:
138 _SESSION_CACHE[pkg] = True
139 else:
140 proven = record is not None and record.status == "proven"
141 _SESSION_CACHE[pkg] = proven
142 if not proven:
143 missing.append(pkg)
145 if not missing:
146 return LLHookResult(exit_code=0)
148 parts = []
149 for pkg in missing:
150 if pkg in stale_ages:
151 parts.append(f'"{pkg}" (stale: {stale_ages[pkg]} days old)')
152 else:
153 parts.append(f'"{pkg}"')
154 pkg_list = ", ".join(parts)
155 hint = (
156 f"[ll: proof-first hint] No learning-test record found for {pkg_list}. "
157 f"You're about to write integration code based on training-data assumptions. "
158 f'Consider: ll-loop run proof-first-task --context task="<your task>"'
159 )
161 if disc.mode == "block":
162 return LLHookResult(exit_code=2, feedback=hint)
163 return LLHookResult(exit_code=0, feedback=hint)