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

2

3 from __future__ import annotations

4

5 import json

6 import os

7 import sys

8 from dataclasses import dataclass

9 from pathlib import Path

10

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)

20

21

22@dataclass(frozen=True)

23 class TemplateMatch:

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

25

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.)

31

32

33 def _find_templates_dir() -> Path:

34 """Locate the in-package templates/ directory.

35

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"

46

47

48 def _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))

51

52

53 def _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

69

70

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

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

73

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"}

78

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

92

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 )

109

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

122

123

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

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

126

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.

134

135 Args:

136 root: Project root directory to inspect.

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

138

139 Returns:

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

141

142 Raises:

143 FileNotFoundError: If no templates are found at all.

144 """

145 if templates_dir is None:

146 templates_dir = _find_templates_dir()

147

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 )

160

161 templates = _load_templates(templates_dir)

162 if not templates:

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

164

165 fallback: TemplateMatch | None = None

166 matches: list[TemplateMatch] = []

167

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

172

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

183

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

187

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

191

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 )

201

202 if matches:

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

204 return matches[0]

205

206 if fallback is not None:

207 return fallback

208

209 raise FileNotFoundError(

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

211 )