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

78 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:11 -0500

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

2 

3from __future__ import annotations 

4 

5import json 

6from dataclasses import dataclass 

7from pathlib import Path 

8 

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

10_SECTION_TEMPLATES = frozenset( 

11 { 

12 "bug-sections.json", 

13 "feat-sections.json", 

14 "enh-sections.json", 

15 "epic-sections.json", 

16 } 

17) 

18 

19 

20@dataclass(frozen=True) 

21class TemplateMatch: 

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

23 

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

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

26 template_path: Path # Absolute path to the template JSON 

27 meta: dict # The _meta block 

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

29 

30 

31def _find_templates_dir() -> Path: 

32 """Locate the templates/ directory relative to this package. 

33 

34 Path: scripts/little_loops/init/detect.py → four parents up → project root. 

35 """ 

36 return Path(__file__).parent.parent.parent.parent / "templates" 

37 

38 

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

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

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

42 

43 

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

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

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

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

48 if tmpl_path.name in _SECTION_TEMPLATES: 

49 continue 

50 try: 

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

52 except (json.JSONDecodeError, OSError): 

53 continue 

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

55 if "detect" not in meta: 

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

57 continue 

58 results.append((data, tmpl_path)) 

59 return results 

60 

61 

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

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

64 

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

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

67 """ 

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

69 

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

71 found: list[str] = [] 

72 seen: set[str] = set() 

73 for pattern in patterns: 

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

75 parts = p.relative_to(project_root).parts 

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

77 continue 

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

79 if rel not in seen: 

80 seen.add(rel) 

81 found.append(rel) 

82 return found 

83 

84 arch_files = _find( 

85 [ 

86 "**/architecture*.md", 

87 "**/design*.md", 

88 "**/api*.md", 

89 "docs/*.md", 

90 ] 

91 ) 

92 product_files = _find( 

93 [ 

94 "**/goal*.md", 

95 "**/roadmap*.md", 

96 "**/vision*.md", 

97 "**/requirements*.md", 

98 ] 

99 ) 

100 

101 categories: dict = {} 

102 if arch_files: 

103 categories["architecture"] = { 

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

105 "files": arch_files, 

106 } 

107 if product_files: 

108 categories["product"] = { 

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

110 "files": product_files, 

111 } 

112 return categories 

113 

114 

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

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

117 

118 Algorithm: 

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

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

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

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

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

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

125 

126 Args: 

127 root: Project root directory to inspect. 

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

129 

130 Returns: 

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

132 

133 Raises: 

134 FileNotFoundError: If no templates are found at all. 

135 """ 

136 if templates_dir is None: 

137 templates_dir = _find_templates_dir() 

138 

139 templates = _load_templates(templates_dir) 

140 if not templates: 

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

142 

143 fallback: TemplateMatch | None = None 

144 matches: list[TemplateMatch] = [] 

145 

146 for data, tmpl_path in templates: 

147 meta = data["_meta"] 

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

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

150 

151 if not detect_files: 

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

153 fallback = TemplateMatch( 

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

155 filename=tmpl_path.name, 

156 template_path=tmpl_path, 

157 meta=meta, 

158 data=data, 

159 ) 

160 continue 

161 

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

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

164 continue 

165 

166 # Excluded if any exclude file also exists 

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

168 continue 

169 

170 matches.append( 

171 TemplateMatch( 

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

173 filename=tmpl_path.name, 

174 template_path=tmpl_path, 

175 meta=meta, 

176 data=data, 

177 ) 

178 ) 

179 

180 if matches: 

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

182 return matches[0] 

183 

184 if fallback is not None: 

185 return fallback 

186 

187 raise FileNotFoundError( 

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

189 )