Coverage for little_loops / doc_counts.py: 0%

113 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""Documentation count verification utilities. 

2 

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

4match actual file counts in the codebase. 

5""" 

6 

7import re 

8from dataclasses import dataclass, field 

9from pathlib import Path 

10 

11# Documentation files to check 

12DOC_FILES = [ 

13 "README.md", 

14 "CONTRIBUTING.md", 

15 "docs/ARCHITECTURE.md", 

16] 

17 

18# Directories to count 

19COUNT_TARGETS = { 

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

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

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

23} 

24 

25 

26@dataclass 

27class 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 

39class 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 

54class FixResult: 

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

56 

57 fixed_count: int 

58 files_modified: list[str] 

59 

60 

61def 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 

82def 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 

110def 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 

160def 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 

186def 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 

215def 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 

244def 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 )