Coverage for little_loops/doc_counts.py: 0%

1"""Documentation count verification utilities.

2

3 Provides automated verification that documented counts (commands, agents, skills)

4 match actual file counts in the codebase.

5"""

6

7 import re

8 from dataclasses import dataclass, field

9 from pathlib import Path

10

11# Documentation files to check

12 DOC_FILES = [

13 "README.md",

14 "CONTRIBUTING.md",

15 "docs/ARCHITECTURE.md",

16]

17

18# Directories to count

19 COUNT_TARGETS = {

20 "commands": ("commands", "*.md"),

21 "agents": ("agents", "*.md"),

22 "skills": ("skills", "SKILL.md"),

23}

24

25

26@dataclass

27 class CountResult:

28 """Result of counting files in a directory."""

29

30 category: str

31 actual: int

32 documented: int | None = None

33 file: str | None = None

34 line: int | None = None

35 matches: bool = True

36

37

38@dataclass

39 class VerificationResult:

40 """Overall verification result."""

41

42 total_checked: int = 0

43 mismatches: list[CountResult] = field(default_factory=list)

44 all_match: bool = True

45

46 def add_result(self, result: CountResult) -> None:

47 """Add a result and track mismatches."""

48 if not result.matches:

49 self.mismatches.append(result)

50 self.all_match = False

51

52

53@dataclass

54 class FixResult:

55 """Result of fixing counts."""

56

57 fixed_count: int

58 files_modified: list[str]

59

60

61 def count_files(directory: str, pattern: str, base_dir: Path | None = None) -> int:

62 """Count files matching pattern in directory.

63

64 Args:

65 directory: Directory name relative to base_dir

66 pattern: Glob pattern (e.g., "*.md" or "SKILL.md")

67 base_dir: Base directory path (defaults to current working directory)

68

69 Returns:

70 Number of matching files

71 """

72 if base_dir is None:

73 base_dir = Path.cwd()

74 dir_path = base_dir / directory

75 if not dir_path.exists():

76 return 0

77

78 # Use rglob for recursive search to handle subdirectories

79 return len(list(dir_path.rglob(pattern)))

80

81

82 def extract_count_from_line(line: str, category: str) -> int | None:

83 """Extract count from a documentation line.

84

85 Handles multiple formats:

86 - "34 commands" or "34 slash commands"

87 - "8 agents" or "8 specialized agents"

88 - "6 skills" or "6 skill definitions"

89

90 Args:

91 line: Line text to search

92 category: Category name (commands, agents, skills)

93

94 Returns:

95 Extracted count or None if not found

96 """

97 # For skills, also match singular "skill" (e.g., "skill definitions")

98 # Pattern matches: number followed by optional words and category name

99 # Examples: "34 commands", "8 specialized agents", "6 skill definitions"

100 if category == "skills":

101 # Match both "skills" and "skill" (singular)

102 pattern = r"(\d+)\s+\w*\s*skills?"

103 else:

104 pattern = rf"(\d+)\s+\w*\s*{category}"

105

106 match = re.search(pattern, line, re.IGNORECASE)

107 return int(match.group(1)) if match else None

108

109

110 def verify_documentation(

111 base_dir: Path | None = None,

112) -> VerificationResult:

113 """Verify all documented counts against actual file counts.

114

115 Args:

116 base_dir: Base directory path (defaults to current working directory)

117

118 Returns:

119 VerificationResult with all results

120 """

121 if base_dir is None:

122 base_dir = Path.cwd()

123 result = VerificationResult(total_checked=0)

124

125 # Get actual counts

126 actual_counts: dict[str, int] = {}

127 for category, (directory, pattern) in COUNT_TARGETS.items():

128 actual_counts[category] = count_files(directory, pattern, base_dir)

129

130 # Check each documentation file

131 for doc_file in DOC_FILES:

132 doc_path = base_dir / doc_file

133 if not doc_path.exists():

134 continue

135

136 content = doc_path.read_text()

137 lines = content.splitlines()

138

139 for line_num, line in enumerate(lines, start=1):

140 for category in COUNT_TARGETS:

141 documented = extract_count_from_line(line, category)

142 if documented is not None:

143 actual = actual_counts[category]

144 matches = documented == actual

145

146 count_result = CountResult(

147 category=category,

148 actual=actual,

149 documented=documented,

150 file=str(doc_file),

151 line=line_num,

152 matches=matches,

153 )

154 result.add_result(count_result)

155 result.total_checked += 1

156

157 return result

158

159

160 def format_result_text(result: VerificationResult) -> str:

161 """Format verification result as text.

162

163 Args:

164 result: Verification result

165

166 Returns:

167 Formatted text output

168 """

169 lines = ["Documentation Count Verification", "=" * 40]

170

171 if result.all_match:

172 lines.append(f"✓ All {result.total_checked} count(s) match!")

173 else:

174 lines.append(f"✗ Found {len(result.mismatches)} mismatch(es):")

175 lines.append("")

176

177 for mismatch in result.mismatches:

178 lines.append(

179 f" {mismatch.category}: documented={mismatch.documented}, actual={mismatch.actual}"

180 )

181 lines.append(f" at {mismatch.file}:{mismatch.line}")

182

183 return "\n".join(lines)

184

185

186 def format_result_json(result: VerificationResult) -> str:

187 """Format verification result as JSON.

188

189 Args:

190 result: Verification result

191

192 Returns:

193 JSON string

194 """

195 import json

196

197 data = {

198 "all_match": result.all_match,

199 "total_checked": result.total_checked,

200 "mismatches": [

201 {

202 "category": m.category,

203 "documented": m.documented,

204 "actual": m.actual,

205 "file": m.file,

206 "line": m.line,

207 }

208 for m in result.mismatches

209 ],

210 }

211

212 return json.dumps(data, indent=2)

213

214

215 def format_result_markdown(result: VerificationResult) -> str:

216 """Format verification result as Markdown.

217

218 Args:

219 result: Verification result

220

221 Returns:

222 Markdown formatted string

223 """

224 lines = ["# Documentation Count Verification", ""]

225

226 if result.all_match:

227 lines.append("## ✅ All Counts Match")

228 lines.append(f"\nAll {result.total_checked} documented count(s) are accurate.")

229 else:

230 lines.append("## ❌ Mismatches Found")

231 lines.append("")

232 lines.append("| Category | Documented | Actual | Location |")

233 lines.append("|----------|-----------|--------|----------|")

234

235 for mismatch in result.mismatches:

236 lines.append(

237 f"| {mismatch.category} | {mismatch.documented} | "

238 f"{mismatch.actual} | `{mismatch.file}:{mismatch.line}` |"

239 )

240

241 return "\n".join(lines)

242

243

244 def fix_counts(base_dir: Path, result: VerificationResult) -> FixResult:

245 """Fix count mismatches in documentation files.

246

247 Args:

248 base_dir: Base directory path

249 result: Verification result with mismatches

250

251 Returns:

252 FixResult with counts of fixes made

253 """

254 files_modified: set[str] = set()

255 fixed_count = 0

256

257 # Group mismatches by file

258 mismatches_by_file: dict[str, list[CountResult]] = {}

259 for mismatch in result.mismatches:

260 if mismatch.file:

261 mismatches_by_file.setdefault(mismatch.file, []).append(mismatch)

262

263 # Fix each file

264 for file_path, mismatches in mismatches_by_file.items():

265 doc_path = base_dir / file_path

266 content = doc_path.read_text()

267 lines = content.splitlines()

268

269 for mismatch in mismatches:

270 if mismatch.line is not None and 1 <= mismatch.line <= len(lines):

271 line = lines[mismatch.line - 1]

272

273 # Build regex pattern based on category

274 # For skills, also match singular "skill"

275 if mismatch.category == "skills":

276 pattern = r"(\d+)(\s+\w*\s*skills?)"

277 else:

278 pattern = rf"(\d+)(\s+\w*\s*{re.escape(mismatch.category)})"

279

280 # Replace the count while preserving the rest of the line

281 new_line = re.sub(

282 pattern,

283 str(mismatch.actual) + r"\2",

284 line,

285 count=1, # Only replace first occurrence

286 flags=re.IGNORECASE,

287 )

288

289 if new_line != line:

290 lines[mismatch.line - 1] = new_line

291 fixed_count += 1

292 files_modified.add(file_path)

293

294 # Write back if changes were made

295 if file_path in files_modified:

296 doc_path.write_text("\n".join(lines))

297

298 return FixResult(

299 fixed_count=fixed_count,

300 files_modified=list(files_modified),

301 )