Coverage for little_loops / init / validate.py: 24%
75 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -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=("'little-loops' pip package not installed — ll-* CLI tools unavailable."),
80 install_hint=hint,
81 )
83 if installed != plugin_version:
84 scripts_dir = project_root / "scripts"
85 hint = (
86 f"pip install -e '{scripts_dir}'"
87 if scripts_dir.exists()
88 else "pip install --upgrade little-loops"
89 )
90 return DepWarning(
91 message=(
92 f"little-loops version mismatch: installed {installed!r}, "
93 f"plugin expects {plugin_version!r}."
94 ),
95 install_hint=hint,
96 )
98 return None
101def _check_tool_commands(config: dict[str, Any]) -> list[DepWarning]:
102 """Check PATH availability of base commands in test/lint/type/format_cmd.
104 Ports Step 7.5 of the skill: extracts the first token of each configured
105 command, deduplicates, and warns for any not found on PATH.
106 """
107 project = config.get("project", {})
108 cmd_fields = ["test_cmd", "lint_cmd", "type_cmd", "format_cmd"]
109 base_commands: dict[str, str] = {} # base_cmd → first source field
111 for field in cmd_fields:
112 value = project.get(field)
113 if not value:
114 continue
115 base = value.strip().split()[0] if value.strip() else None
116 if base and base not in base_commands:
117 base_commands[base] = field
119 warnings: list[DepWarning] = []
120 for base_cmd, source_field in base_commands.items():
121 if shutil.which(base_cmd) is None:
122 skill_hint = "/ll:run-tests" if source_field == "test_cmd" else "/ll:check-code"
123 warnings.append(
124 DepWarning(
125 message=(
126 f"'{base_cmd}' not found in PATH — install it before running {skill_hint}"
127 ),
128 )
129 )
131 return warnings
134def validate_deps(
135 config: dict[str, Any] | None = None,
136 plugin_version: str | None = None,
137 project_root: Path | None = None,
138) -> list[DepWarning]:
139 """Validate runtime dependencies and pip package version alignment.
141 Combines Step 7.5 (tool command PATH checks) and Step 9.5 (hook
142 dependencies + pip package check) from the /ll:init skill.
144 All checks are non-blocking: every warning is collected and returned
145 regardless of whether earlier checks failed.
147 Args:
148 config: ll-config.json dict (used for tool command PATH checks).
149 plugin_version: Expected little-loops version string; skipped if None.
150 project_root: Project root for scripts/ dir resolution; skipped if None.
152 Returns:
153 List of DepWarning instances (may be empty).
154 """
155 warnings: list[DepWarning] = []
157 if config:
158 warnings.extend(_check_tool_commands(config))
160 w = _check_jq()
161 if w:
162 warnings.append(w)
164 w = _check_python3()
165 if w:
166 warnings.append(w)
168 w = _check_pyyaml()
169 if w:
170 warnings.append(w)
172 if plugin_version is not None and project_root is not None:
173 w = _check_little_loops_version(plugin_version, project_root)
174 if w:
175 warnings.append(w)
177 return warnings