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

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=( 

80 "'little-loops' pip package not installed — ll-* CLI tools unavailable." 

81 ), 

82 install_hint=hint, 

83 ) 

84 

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 ) 

99 

100 return None 

101 

102 

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

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

105 

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 

112 

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 

120 

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 ) 

133 

134 return warnings 

135 

136 

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. 

143 

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

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

146 

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

148 regardless of whether earlier checks failed. 

149 

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. 

154 

155 Returns: 

156 List of DepWarning instances (may be empty). 

157 """ 

158 warnings: list[DepWarning] = [] 

159 

160 if config: 

161 warnings.extend(_check_tool_commands(config)) 

162 

163 w = _check_jq() 

164 if w: 

165 warnings.append(w) 

166 

167 w = _check_python3() 

168 if w: 

169 warnings.append(w) 

170 

171 w = _check_pyyaml() 

172 if w: 

173 warnings.append(w) 

174 

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) 

179 

180 return warnings