Coverage for little_loops / init / detect.py: 29%
55 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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_project_type(root: Path, templates_dir: Path | None = None) -> TemplateMatch:
63 """Detect the project type by matching template detect patterns against *root*.
65 Algorithm:
66 1. Load all project-type templates from templates_dir.
67 2. Skip templates whose detect_exclude files exist at root.
68 3. Match templates whose detect files exist at root.
69 4. Empty detect list → fallback template (priority: -1).
70 5. If no matches, return the fallback; raise if no fallback found.
71 6. Among matches, return the first (alphabetical); fallback is last resort.
73 Args:
74 root: Project root directory to inspect.
75 templates_dir: Override templates/ location (defaults to auto-discovery).
77 Returns:
78 The best-matching TemplateMatch; falls back to generic.json.
80 Raises:
81 FileNotFoundError: If no templates are found at all.
82 """
83 if templates_dir is None:
84 templates_dir = _find_templates_dir()
86 templates = _load_templates(templates_dir)
87 if not templates:
88 raise FileNotFoundError(f"No project-type templates found in {templates_dir}")
90 fallback: TemplateMatch | None = None
91 matches: list[TemplateMatch] = []
93 for data, tmpl_path in templates:
94 meta = data["_meta"]
95 detect_files: list[str] = meta.get("detect", [])
96 detect_exclude: list[str] = meta.get("detect_exclude", [])
98 if not detect_files:
99 # Empty detect → fallback candidate (generic.json has priority: -1)
100 fallback = TemplateMatch(
101 name=meta.get("name", tmpl_path.stem),
102 filename=tmpl_path.name,
103 template_path=tmpl_path,
104 meta=meta,
105 data=data,
106 )
107 continue
109 # Check if at least one detect indicator exists at root
110 if not any(_glob_match(f, root) for f in detect_files):
111 continue
113 # Excluded if any exclude file also exists
114 if detect_exclude and any(_glob_match(f, root) for f in detect_exclude):
115 continue
117 matches.append(
118 TemplateMatch(
119 name=meta.get("name", tmpl_path.stem),
120 filename=tmpl_path.name,
121 template_path=tmpl_path,
122 meta=meta,
123 data=data,
124 )
125 )
127 if matches:
128 # On multi-match, prefer first alphabetically (templates are sorted on load)
129 return matches[0]
131 if fallback is not None:
132 return fallback
134 raise FileNotFoundError(
135 f"No project-type template matched for {root} and no fallback found in {templates_dir}"
136 )