Coverage for little_loops / init / detect.py: 22%
78 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:30 -0500
1"""Project type detection for headless ll-init."""
3from __future__ import annotations
5import json
6from dataclasses import dataclass
7from pathlib import Path
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)
20@dataclass(frozen=True)
21class TemplateMatch:
22 """A matched project-type template."""
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.)
31def _find_templates_dir() -> Path:
32 """Locate the templates/ directory relative to this package.
34 Path: scripts/little_loops/init/detect.py → four parents up → project root.
35 """
36 return Path(__file__).parent.parent.parent.parent / "templates"
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))
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
62def detect_documents(project_root: Path) -> dict:
63 """Glob architecture and product documents into a categories dict.
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"}
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
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 )
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
115def detect_project_type(root: Path, templates_dir: Path | None = None) -> TemplateMatch:
116 """Detect the project type by matching template detect patterns against *root*.
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.
126 Args:
127 root: Project root directory to inspect.
128 templates_dir: Override templates/ location (defaults to auto-discovery).
130 Returns:
131 The best-matching TemplateMatch; falls back to generic.json.
133 Raises:
134 FileNotFoundError: If no templates are found at all.
135 """
136 if templates_dir is None:
137 templates_dir = _find_templates_dir()
139 templates = _load_templates(templates_dir)
140 if not templates:
141 raise FileNotFoundError(f"No project-type templates found in {templates_dir}")
143 fallback: TemplateMatch | None = None
144 matches: list[TemplateMatch] = []
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", [])
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
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
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
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 )
180 if matches:
181 # On multi-match, prefer first alphabetically (templates are sorted on load)
182 return matches[0]
184 if fallback is not None:
185 return fallback
187 raise FileNotFoundError(
188 f"No project-type template matched for {root} and no fallback found in {templates_dir}"
189 )