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

1"""Learning-test discoverability gate for PreToolUse (FEAT-1742). 

2 

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. 

7 

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""" 

16 

17from __future__ import annotations 

18 

19import datetime 

20import json 

21import re 

22from pathlib import Path 

23 

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 

30 

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) 

35 

36_BUILTIN_SKIP: frozenset[str] = frozenset( 

37 {"__future__", "__builtins__", "typing_extensions", "abc", "io", "re", "json"} 

38) 

39 

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] = {} 

43 

44 

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", {})) 

57 

58 

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")) 

64 

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) 

82 

83 return pkgs 

84 

85 

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) 

90 

91 if not lt_config.enabled: 

92 return LLHookResult(exit_code=0) 

93 

94 disc = lt_config.discoverability 

95 if disc.mode == "off": 

96 return LLHookResult(exit_code=0) 

97 

98 payload = event.payload 

99 tool_name = payload.get("tool_name", "") 

100 tool_input: dict = payload.get("tool_input", {}) or {} 

101 

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) 

110 

111 if not content: 

112 return LLHookResult(exit_code=0) 

113 

114 skip = set(disc.skip_packages) | _BUILTIN_SKIP 

115 packages = _extract_packages(content, file_path) 

116 base_dir = cwd / ".ll" / "learning-tests" 

117 

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) 

144 

145 if not missing: 

146 return LLHookResult(exit_code=0) 

147 

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 ) 

160 

161 if disc.mode == "block": 

162 return LLHookResult(exit_code=2, feedback=hint) 

163 return LLHookResult(exit_code=0, feedback=hint)