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

1"""Dependency validation for headless ll-init.""" 

2 

3from __future__ import annotations 

4 

5import importlib.metadata 

6import shutil 

7import subprocess 

8import sys 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import Any 

12 

13 

14@dataclass 

15class DepWarning: 

16 """A non-blocking dependency warning from validate_deps().""" 

17 

18 message: str 

19 install_hint: str | None = None 

20 

21 

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 

35 

36 

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 

44 

45 

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 

65 

66 

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 ) 

82 

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 ) 

97 

98 return None 

99 

100 

101def _check_tool_commands(config: dict[str, Any]) -> list[DepWarning]: 

102 """Check PATH availability of base commands in test/lint/type/format_cmd. 

103 

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 

110 

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 

118 

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 ) 

130 

131 return warnings 

132 

133 

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. 

140 

141 Combines Step 7.5 (tool command PATH checks) and Step 9.5 (hook 

142 dependencies + pip package check) from the /ll:init skill. 

143 

144 All checks are non-blocking: every warning is collected and returned 

145 regardless of whether earlier checks failed. 

146 

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. 

151 

152 Returns: 

153 List of DepWarning instances (may be empty). 

154 """ 

155 warnings: list[DepWarning] = [] 

156 

157 if config: 

158 warnings.extend(_check_tool_commands(config)) 

159 

160 w = _check_jq() 

161 if w: 

162 warnings.append(w) 

163 

164 w = _check_python3() 

165 if w: 

166 warnings.append(w) 

167 

168 w = _check_pyyaml() 

169 if w: 

170 warnings.append(w) 

171 

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) 

176 

177 return warnings