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

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_project_type(root: Path, templates_dir: Path | None = None) -> TemplateMatch: 

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

64 

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. 

72 

73 Args: 

74 root: Project root directory to inspect. 

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

76 

77 Returns: 

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

79 

80 Raises: 

81 FileNotFoundError: If no templates are found at all. 

82 """ 

83 if templates_dir is None: 

84 templates_dir = _find_templates_dir() 

85 

86 templates = _load_templates(templates_dir) 

87 if not templates: 

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

89 

90 fallback: TemplateMatch | None = None 

91 matches: list[TemplateMatch] = [] 

92 

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", []) 

97 

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 

108 

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 

112 

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 

116 

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 ) 

126 

127 if matches: 

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

129 return matches[0] 

130 

131 if fallback is not None: 

132 return fallback 

133 

134 raise FileNotFoundError( 

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

136 )