Coverage for little_loops / init / validate.py: 24%
75 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""Dependency validation for headless ll-init."""
3from __future__ import annotations
5import importlib.metadata
6import shutil
7import subprocess
8import sys
9from dataclasses import dataclass
10from pathlib import Path
11from typing import Any
14@dataclass
15class DepWarning:
16 """A non-blocking dependency warning from validate_deps()."""
18 message: str
19 install_hint: str | None = None
22def _check_jq() -> DepWarning | None:
23 """Check that jq is on PATH (required by all Claude Code hook adapters)."""
24 if shutil.which("jq") is None:
25 return DepWarning(
26 message=(
27 "'jq' not found in PATH — Claude Code hook adapters "
28 "(hooks/adapters/claude-code/*.sh) will fail silently. "
29 "Adapters parse the host JSON envelope with jq before invoking "
30 "the Python handlers in little_loops.hooks."
31 ),
32 install_hint="https://stedolan.github.io/jq/download/",
33 )
34 return None
37def _check_python3() -> DepWarning | None:
38 """Check that python3 is on PATH (required by the SessionStart adapter)."""
39 if shutil.which("python3") is None:
40 return DepWarning(
41 message="'python3' not found in PATH — SessionStart adapter will fail silently",
42 )
43 return None
46def _check_pyyaml() -> DepWarning | None:
47 """Check that pyyaml is importable (required for ll.local.md parsing)."""
48 try:
49 result = subprocess.run(
50 [sys.executable, "-c", "import yaml"],
51 capture_output=True,
52 timeout=10,
53 )
54 if result.returncode != 0:
55 return DepWarning(
56 message=(
57 "'pyyaml' not installed — SessionStart config merge will fail silently "
58 "(little_loops.hooks.session_start uses yaml to parse .ll/ll.local.md)"
59 ),
60 install_hint="pip install pyyaml",
61 )
62 except (subprocess.TimeoutExpired, FileNotFoundError):
63 pass
64 return None
67def _check_little_loops_version(plugin_version: str, project_root: Path) -> DepWarning | None:
68 """Check that the installed little-loops pip package matches *plugin_version*."""
69 try:
70 installed = importlib.metadata.version("little-loops")
71 except importlib.metadata.PackageNotFoundError:
72 scripts_dir = project_root / "scripts"
73 hint = (
74 f"pip install -e '{scripts_dir}'"
75 if scripts_dir.exists()
76 else "pip install little-loops"
77 )
78 return DepWarning(
79 message=(
80 "'little-loops' pip package not installed — ll-* CLI tools unavailable."
81 ),
82 install_hint=hint,
83 )
85 if installed != plugin_version:
86 scripts_dir = project_root / "scripts"
87 hint = (
88 f"pip install -e '{scripts_dir}'"
89 if scripts_dir.exists()
90 else "pip install --upgrade little-loops"
91 )
92 return DepWarning(
93 message=(
94 f"little-loops version mismatch: installed {installed!r}, "
95 f"plugin expects {plugin_version!r}."
96 ),
97 install_hint=hint,
98 )
100 return None
103def _check_tool_commands(config: dict[str, Any]) -> list[DepWarning]:
104 """Check PATH availability of base commands in test/lint/type/format_cmd.
106 Ports Step 7.5 of the skill: extracts the first token of each configured
107 command, deduplicates, and warns for any not found on PATH.
108 """
109 project = config.get("project", {})
110 cmd_fields = ["test_cmd", "lint_cmd", "type_cmd", "format_cmd"]
111 base_commands: dict[str, str] = {} # base_cmd → first source field
113 for field in cmd_fields:
114 value = project.get(field)
115 if not value:
116 continue
117 base = value.strip().split()[0] if value.strip() else None
118 if base and base not in base_commands:
119 base_commands[base] = field
121 warnings: list[DepWarning] = []
122 for base_cmd, source_field in base_commands.items():
123 if shutil.which(base_cmd) is None:
124 skill_hint = "/ll:run-tests" if source_field == "test_cmd" else "/ll:check-code"
125 warnings.append(
126 DepWarning(
127 message=(
128 f"'{base_cmd}' not found in PATH — install it before "
129 f"running {skill_hint}"
130 ),
131 )
132 )
134 return warnings
137def validate_deps(
138 config: dict[str, Any] | None = None,
139 plugin_version: str | None = None,
140 project_root: Path | None = None,
141) -> list[DepWarning]:
142 """Validate runtime dependencies and pip package version alignment.
144 Combines Step 7.5 (tool command PATH checks) and Step 9.5 (hook
145 dependencies + pip package check) from the /ll:init skill.
147 All checks are non-blocking: every warning is collected and returned
148 regardless of whether earlier checks failed.
150 Args:
151 config: ll-config.json dict (used for tool command PATH checks).
152 plugin_version: Expected little-loops version string; skipped if None.
153 project_root: Project root for scripts/ dir resolution; skipped if None.
155 Returns:
156 List of DepWarning instances (may be empty).
157 """
158 warnings: list[DepWarning] = []
160 if config:
161 warnings.extend(_check_tool_commands(config))
163 w = _check_jq()
164 if w:
165 warnings.append(w)
167 w = _check_python3()
168 if w:
169 warnings.append(w)
171 w = _check_pyyaml()
172 if w:
173 warnings.append(w)
175 if plugin_version is not None and project_root is not None:
176 w = _check_little_loops_version(plugin_version, project_root)
177 if w:
178 warnings.append(w)
180 return warnings