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
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""Documentation count verification utilities.
3Provides automated verification that documented counts (commands, agents, skills)
4match actual file counts in the codebase.
5"""
7import re
8from dataclasses import dataclass, field
9from pathlib import Path
11# Documentation files to check
12DOC_FILES = [
13 "README.md",
14 "CONTRIBUTING.md",
15 "docs/ARCHITECTURE.md",
16]
18# Directories to count
19COUNT_TARGETS = {
20 "commands": ("commands", "*.md"),
21 "agents": ("agents", "*.md"),
22 "skills": ("skills", "SKILL.md"),
23}
26@dataclass
27class CountResult:
28 """Result of counting files in a directory."""
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
38@dataclass
39class VerificationResult:
40 """Overall verification result."""
42 total_checked: int = 0
43 mismatches: list[CountResult] = field(default_factory=list)
44 all_match: bool = True
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
53@dataclass
54class FixResult:
55 """Result of fixing counts."""
57 fixed_count: int
58 files_modified: list[str]
61def count_files(directory: str, pattern: str, base_dir: Path | None = None) -> int:
62 """Count files matching pattern in directory.
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)
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
78 # Use rglob for recursive search to handle subdirectories
79 return len(list(dir_path.rglob(pattern)))
82def extract_count_from_line(line: str, category: str) -> int | None:
83 """Extract count from a documentation line.
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"
90 Args:
91 line: Line text to search
92 category: Category name (commands, agents, skills)
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}"
106 match = re.search(pattern, line, re.IGNORECASE)
107 return int(match.group(1)) if match else None
110def verify_documentation(
111 base_dir: Path | None = None,
112) -> VerificationResult:
113 """Verify all documented counts against actual file counts.
115 Args:
116 base_dir: Base directory path (defaults to current working directory)
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)
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)
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
136 content = doc_path.read_text()
137 lines = content.splitlines()
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
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
157 return result
160def format_result_text(result: VerificationResult) -> str:
161 """Format verification result as text.
163 Args:
164 result: Verification result
166 Returns:
167 Formatted text output
168 """
169 lines = ["Documentation Count Verification", "=" * 40]
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("")
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}")
183 return "\n".join(lines)
186def format_result_json(result: VerificationResult) -> str:
187 """Format verification result as JSON.
189 Args:
190 result: Verification result
192 Returns:
193 JSON string
194 """
195 import json
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 }
212 return json.dumps(data, indent=2)
215def format_result_markdown(result: VerificationResult) -> str:
216 """Format verification result as Markdown.
218 Args:
219 result: Verification result
221 Returns:
222 Markdown formatted string
223 """
224 lines = ["# Documentation Count Verification", ""]
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("|----------|-----------|--------|----------|")
235 for mismatch in result.mismatches:
236 lines.append(
237 f"| {mismatch.category} | {mismatch.documented} | "
238 f"{mismatch.actual} | `{mismatch.file}:{mismatch.line}` |"
239 )
241 return "\n".join(lines)
244def fix_counts(base_dir: Path, result: VerificationResult) -> FixResult:
245 """Fix count mismatches in documentation files.
247 Args:
248 base_dir: Base directory path
249 result: Verification result with mismatches
251 Returns:
252 FixResult with counts of fixes made
253 """
254 files_modified: set[str] = set()
255 fixed_count = 0
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)
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()
269 for mismatch in mismatches:
270 if mismatch.line is not None and 1 <= mismatch.line <= len(lines):
271 line = lines[mismatch.line - 1]
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)})"
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 )
289 if new_line != line:
290 lines[mismatch.line - 1] = new_line
291 fixed_count += 1
292 files_modified.add(file_path)
294 # Write back if changes were made
295 if file_path in files_modified:
296 doc_path.write_text("\n".join(lines))
298 return FixResult(
299 fixed_count=fixed_count,
300 files_modified=list(files_modified),
301 )