Coverage for little_loops / hooks / learning_tests_gate.py: 0%

88 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -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 json 

20import re 

21from pathlib import Path 

22 

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 

27 

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) 

33 

34_BUILTIN_SKIP: frozenset[str] = frozenset( 

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

36) 

37 

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

41 

42 

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

55 

56 

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

62 

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) 

80 

81 return pkgs 

82 

83 

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) 

88 

89 if not lt_config.enabled: 

90 return LLHookResult(exit_code=0) 

91 

92 disc = lt_config.discoverability 

93 if disc.mode == "off": 

94 return LLHookResult(exit_code=0) 

95 

96 payload = event.payload 

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

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

99 

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) 

108 

109 if not content: 

110 return LLHookResult(exit_code=0) 

111 

112 skip = set(disc.skip_packages) | _BUILTIN_SKIP 

113 packages = _extract_packages(content, file_path) 

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

115 

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) 

129 

130 if not missing: 

131 return LLHookResult(exit_code=0) 

132 

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 ) 

139 

140 if disc.mode == "block": 

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

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