Coverage for little_loops / init / detect.py: 22%

88 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""Project type detection for headless ll-init.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import sys 

8from dataclasses import dataclass 

9from pathlib import Path 

10 

11# Template filenames that are NOT project-type templates — skip during detection 

12_SECTION_TEMPLATES = frozenset( 

13 { 

14 "bug-sections.json", 

15 "feat-sections.json", 

16 "enh-sections.json", 

17 "epic-sections.json", 

18 } 

19) 

20 

21 

22@dataclass(frozen=True) 

23class TemplateMatch: 

24 """A matched project-type template.""" 

25 

26 name: str # Human-readable name from _meta.name (e.g., "Python (Generic)") 

27 filename: str # JSON filename (e.g., "python-generic.json") 

28 template_path: Path # Absolute path to the template JSON 

29 meta: dict # The _meta block 

30 data: dict # Full template content (project, scan, issues, etc.) 

31 

32 

33def _find_templates_dir() -> Path: 

34 """Locate the in-package templates/ directory. 

35 

36 Honors CLAUDE_PLUGIN_ROOT only when its templates/ subdirectory exists 

37 (legacy override). Falls back to the in-package location so pip-installed 

38 and editable installs both resolve correctly without env vars. 

39 """ 

40 env_root = os.environ.get("CLAUDE_PLUGIN_ROOT") 

41 if env_root: 

42 env_path = Path(env_root) / "templates" 

43 if env_path.is_dir(): 

44 return env_path 

45 return Path(__file__).resolve().parent.parent / "templates" 

46 

47 

48def _glob_match(pattern: str, root: Path) -> bool: 

49 """Return True if *pattern* matches at least one entry directly under *root*.""" 

50 return any(True for _ in root.glob(pattern)) 

51 

52 

53def _load_templates(templates_dir: Path) -> list[tuple[dict, Path]]: 

54 """Load all project-type template JSON files from *templates_dir*.""" 

55 results: list[tuple[dict, Path]] = [] 

56 for tmpl_path in sorted(templates_dir.glob("*.json")): 

57 if tmpl_path.name in _SECTION_TEMPLATES: 

58 continue 

59 try: 

60 data = json.loads(tmpl_path.read_text(encoding="utf-8")) 

61 except (json.JSONDecodeError, OSError): 

62 continue 

63 meta = data.get("_meta", {}) 

64 if "detect" not in meta: 

65 # Not a project-type template (e.g., section template without _meta.detect) 

66 continue 

67 results.append((data, tmpl_path)) 

68 return results 

69 

70 

71def detect_documents(project_root: Path) -> dict: 

72 """Glob architecture and product documents into a categories dict. 

73 

74 Patterns mirror the /ll:init skill's document auto-detection logic. 

75 Returns a dict suitable for config["documents"]["categories"]; empty if no docs found. 

76 """ 

77 _EXCLUDE_DIRS = {".git", "node_modules", ".issues", ".ll", "dist", "build"} 

78 

79 def _find(patterns: list[str]) -> list[str]: 

80 found: list[str] = [] 

81 seen: set[str] = set() 

82 for pattern in patterns: 

83 for p in sorted(project_root.glob(pattern)): 

84 parts = p.relative_to(project_root).parts 

85 if any(part in _EXCLUDE_DIRS for part in parts): 

86 continue 

87 rel = str(p.relative_to(project_root)) 

88 if rel not in seen: 

89 seen.add(rel) 

90 found.append(rel) 

91 return found 

92 

93 arch_files = _find( 

94 [ 

95 "**/architecture*.md", 

96 "**/design*.md", 

97 "**/api*.md", 

98 "docs/*.md", 

99 ] 

100 ) 

101 product_files = _find( 

102 [ 

103 "**/goal*.md", 

104 "**/roadmap*.md", 

105 "**/vision*.md", 

106 "**/requirements*.md", 

107 ] 

108 ) 

109 

110 categories: dict = {} 

111 if arch_files: 

112 categories["architecture"] = { 

113 "description": "Architecture and design documents", 

114 "files": arch_files, 

115 } 

116 if product_files: 

117 categories["product"] = { 

118 "description": "Product and requirements documents", 

119 "files": product_files, 

120 } 

121 return categories 

122 

123 

124def detect_project_type(root: Path, templates_dir: Path | None = None) -> TemplateMatch: 

125 """Detect the project type by matching template detect patterns against *root*. 

126 

127 Algorithm: 

128 1. Load all project-type templates from templates_dir. 

129 2. Skip templates whose detect_exclude files exist at root. 

130 3. Match templates whose detect files exist at root. 

131 4. Empty detect list → fallback template (priority: -1). 

132 5. If no matches, return the fallback; raise if no fallback found. 

133 6. Among matches, return the first (alphabetical); fallback is last resort. 

134 

135 Args: 

136 root: Project root directory to inspect. 

137 templates_dir: Override templates/ location (defaults to auto-discovery). 

138 

139 Returns: 

140 The best-matching TemplateMatch; falls back to generic.json. 

141 

142 Raises: 

143 FileNotFoundError: If no templates are found at all. 

144 """ 

145 if templates_dir is None: 

146 templates_dir = _find_templates_dir() 

147 

148 if not templates_dir.exists(): 

149 print( 

150 f" Warning: templates/ not found at {templates_dir}; defaulting to generic project type", 

151 file=sys.stderr, 

152 ) 

153 return TemplateMatch( 

154 name="Generic", 

155 filename="generic.json", 

156 template_path=templates_dir / "generic.json", 

157 meta={"name": "Generic", "detect": []}, 

158 data={}, 

159 ) 

160 

161 templates = _load_templates(templates_dir) 

162 if not templates: 

163 raise FileNotFoundError(f"No project-type templates found in {templates_dir}") 

164 

165 fallback: TemplateMatch | None = None 

166 matches: list[TemplateMatch] = [] 

167 

168 for data, tmpl_path in templates: 

169 meta = data["_meta"] 

170 detect_files: list[str] = meta.get("detect", []) 

171 detect_exclude: list[str] = meta.get("detect_exclude", []) 

172 

173 if not detect_files: 

174 # Empty detect → fallback candidate (generic.json has priority: -1) 

175 fallback = TemplateMatch( 

176 name=meta.get("name", tmpl_path.stem), 

177 filename=tmpl_path.name, 

178 template_path=tmpl_path, 

179 meta=meta, 

180 data=data, 

181 ) 

182 continue 

183 

184 # Check if at least one detect indicator exists at root 

185 if not any(_glob_match(f, root) for f in detect_files): 

186 continue 

187 

188 # Excluded if any exclude file also exists 

189 if detect_exclude and any(_glob_match(f, root) for f in detect_exclude): 

190 continue 

191 

192 matches.append( 

193 TemplateMatch( 

194 name=meta.get("name", tmpl_path.stem), 

195 filename=tmpl_path.name, 

196 template_path=tmpl_path, 

197 meta=meta, 

198 data=data, 

199 ) 

200 ) 

201 

202 if matches: 

203 # On multi-match, prefer first alphabetically (templates are sorted on load) 

204 return matches[0] 

205 

206 if fallback is not None: 

207 return fallback 

208 

209 raise FileNotFoundError( 

210 f"No project-type template matched for {root} and no fallback found in {templates_dir}" 

211 )