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

62 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

1"""PostToolUse install-nudge gate for Bash tool calls (ENH-2212). 

2 

3When a Bash command contains a package-manager install invocation (pip, uv, 

4poetry, npm, yarn, pnpm), extracts the package name, queries the Learning Test 

5Registry, and emits a nudge via ``/ll:explore-api`` if no proven record exists 

6or the existing record is stale. 

7 

8Gated behind ``learning_tests.enabled`` in ``.ll/ll-config.json``. When 

9disabled (default), the handler is a no-op — no registry I/O occurs. 

10 

11Supported install commands: 

12 pip install <pkg> pip3 install <pkg> 

13 uv add <pkg> poetry add <pkg> 

14 npm install <pkg> yarn add <pkg> pnpm add <pkg> 

15 

16Out of scope (silently skipped): 

17 pip install -r requirements.txt (flag-prefixed, no single package name) 

18 Transitive or bulk installs (only the first explicit package token) 

19""" 

20 

21from __future__ import annotations 

22 

23import json 

24import re 

25from pathlib import Path 

26 

27from little_loops.config.core import resolve_config_path 

28from little_loops.config.features import LearningTestsConfig 

29from little_loops.hooks.types import LLHookEvent, LLHookResult 

30from little_loops.learning_tests import check_learning_test 

31from little_loops.learning_tests.gate import format_nudge_message, is_record_stale 

32 

33# Matches pip/pip3/uv/poetry/npm/yarn/pnpm install/add followed by the first 

34# non-flag package token. The negative lookahead (?!-) rejects flag tokens 

35# like -r, --save-dev, etc., causing the whole match to fail when the first 

36# argument is a flag (e.g. pip install -r requirements.txt → no match). 

37_INSTALL_RE = re.compile( 

38 r"\b(?:pip3?\s+install|uv\s+add|poetry\s+add|npm\s+install|yarn\s+add|pnpm\s+add)" 

39 r"\s+((?!-)\S+)", 

40 re.IGNORECASE, 

41) 

42 

43# Session-level cache: normalized package name → True (proven+fresh) / False. 

44# Avoids repeated registry reads when the same package is installed multiple 

45# times in one session. 

46_SESSION_CACHE: dict[str, bool] = {} 

47 

48 

49def _load_lt_config(cwd: Path) -> LearningTestsConfig: 

50 """Load LearningTestsConfig from project config, returning defaults on miss.""" 

51 config_path = resolve_config_path(cwd) 

52 if config_path is None: 

53 return LearningTestsConfig() 

54 try: 

55 data = json.loads(config_path.read_text(encoding="utf-8")) 

56 except (OSError, json.JSONDecodeError): 

57 return LearningTestsConfig() 

58 if not isinstance(data, dict): 

59 return LearningTestsConfig() 

60 return LearningTestsConfig.from_dict(data.get("learning_tests", {})) 

61 

62 

63def _normalize_pkg(raw: str) -> str | None: 

64 """Normalize a raw package token from the install command. 

65 

66 Strips surrounding quotes, extras (``[...]``), and version specifiers 

67 (``>=``, ``==``, ``~=``, ``!=``, ``<=``, ``<``, ``>``). Returns ``None`` 

68 for flags (``-r``, ``--requirement``), empty strings, or tokens that 

69 resolve to empty after stripping. 

70 """ 

71 if not raw: 

72 return None 

73 # Flags (should be filtered by regex, but defensive) 

74 if raw.startswith("-"): 

75 return None 

76 # Strip surrounding quotes 

77 raw = raw.strip("\"'") 

78 if not raw or raw.startswith("-"): 

79 return None 

80 # Strip extras: anthropic[bedrock] → anthropic 

81 raw = re.sub(r"\[.*?\]", "", raw) 

82 # Strip version specifiers: requests>=2.0 → requests 

83 raw = re.sub(r"[><=!~][^\s]*$", "", raw) 

84 raw = raw.strip() 

85 return raw if raw else None 

86 

87 

88def gate(event: LLHookEvent) -> LLHookResult: 

89 """Check Bash install commands against the Learning Test Registry. 

90 

91 Returns a nudge (exit_code=0, feedback=...) when the installed package has 

92 no proven record or the existing record is stale. Silent pass otherwise. 

93 """ 

94 cwd = Path(event.cwd) if event.cwd else Path.cwd() 

95 lt_config = _load_lt_config(cwd) 

96 

97 if not lt_config.enabled: 

98 return LLHookResult(exit_code=0) 

99 

100 payload = event.payload or {} 

101 tool_input = payload.get("tool_input", {}) or {} 

102 cmd = tool_input.get("command") or "" 

103 

104 m = _INSTALL_RE.search(cmd) 

105 if not m: 

106 return LLHookResult(exit_code=0) 

107 

108 pkg = _normalize_pkg(m.group(1)) 

109 if not pkg: 

110 return LLHookResult(exit_code=0) 

111 

112 # Session cache hit 

113 if pkg in _SESSION_CACHE: 

114 if _SESSION_CACHE[pkg]: 

115 return LLHookResult(exit_code=0) 

116 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=False)) 

117 

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

119 record = check_learning_test(pkg, base_dir=base_dir) 

120 

121 if record is not None and record.status == "proven": 

122 if is_record_stale(record, lt_config.stale_after_days): 

123 _SESSION_CACHE[pkg] = False 

124 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=True)) 

125 _SESSION_CACHE[pkg] = True 

126 return LLHookResult(exit_code=0) 

127 

128 _SESSION_CACHE[pkg] = False 

129 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=False))