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
« 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).
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.
8Gated behind ``learning_tests.enabled`` in ``.ll/ll-config.json``. When
9disabled (default), the handler is a no-op — no registry I/O occurs.
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>
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"""
21from __future__ import annotations
23import json
24import re
25from pathlib import Path
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
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)
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] = {}
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", {}))
63def _normalize_pkg(raw: str) -> str | None:
64 """Normalize a raw package token from the install command.
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
88def gate(event: LLHookEvent) -> LLHookResult:
89 """Check Bash install commands against the Learning Test Registry.
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)
97 if not lt_config.enabled:
98 return LLHookResult(exit_code=0)
100 payload = event.payload or {}
101 tool_input = payload.get("tool_input", {}) or {}
102 cmd = tool_input.get("command") or ""
104 m = _INSTALL_RE.search(cmd)
105 if not m:
106 return LLHookResult(exit_code=0)
108 pkg = _normalize_pkg(m.group(1))
109 if not pkg:
110 return LLHookResult(exit_code=0)
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))
118 base_dir = cwd / ".ll" / "learning-tests"
119 record = check_learning_test(pkg, base_dir=base_dir)
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)
128 _SESSION_CACHE[pkg] = False
129 return LLHookResult(exit_code=0, feedback=format_nudge_message(pkg, stale=False))