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

2

3 When the host is about to execute a Write or Edit tool call, parses the

4 file content for external package imports, queries the Learning Test

5 Registry for each package, and emits a nudge when an import has no

6 proven record and discoverability is enabled.

7

8 Config 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

17 from __future__ import annotations

18

19 import datetime

20 import json

21 import re

22 from pathlib import Path

23

24 from little_loops.config.core import resolve_config_path

25 from little_loops.config.features import LearningTestsConfig

26 from little_loops.hooks.types import LLHookEvent, LLHookResult

27 from little_loops.learning_tests import check_learning_test

28 from little_loops.learning_tests.gate import is_record_stale

29 from 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

45 def _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

59 def _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

86 def 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)