Coverage for little_loops / init / detect.py: 22%
88 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"""Project type detection for headless ll-init."""
3from __future__ import annotations
5import json
6import os
7import sys
8from dataclasses import dataclass
9from pathlib import Path
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)
22@dataclass(frozen=True)
23class TemplateMatch:
24 """A matched project-type template."""
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.)
33def _find_templates_dir() -> Path:
34 """Locate the in-package templates/ directory.
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"
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))
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
71def detect_documents(project_root: Path) -> dict:
72 """Glob architecture and product documents into a categories dict.
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"}
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
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 )
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
124def detect_project_type(root: Path, templates_dir: Path | None = None) -> TemplateMatch:
125 """Detect the project type by matching template detect patterns against *root*.
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.
135 Args:
136 root: Project root directory to inspect.
137 templates_dir: Override templates/ location (defaults to auto-discovery).
139 Returns:
140 The best-matching TemplateMatch; falls back to generic.json.
142 Raises:
143 FileNotFoundError: If no templates are found at all.
144 """
145 if templates_dir is None:
146 templates_dir = _find_templates_dir()
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 )
161 templates = _load_templates(templates_dir)
162 if not templates:
163 raise FileNotFoundError(f"No project-type templates found in {templates_dir}")
165 fallback: TemplateMatch | None = None
166 matches: list[TemplateMatch] = []
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", [])
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
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
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
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 )
202 if matches:
203 # On multi-match, prefer first alphabetically (templates are sorted on load)
204 return matches[0]
206 if fallback is not None:
207 return fallback
209 raise FileNotFoundError(
210 f"No project-type template matched for {root} and no fallback found in {templates_dir}"
211 )