@matt82198/aesop 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Verify test suite counts in tests/CLAUDE.md match actual test files on disk.
4
+
5
+ Supports two modes:
6
+ - --check (default): Fail if counts drift from actual files (exit 1 on drift)
7
+ - --fix: Auto-rewrite counts in tests/CLAUDE.md to match actual files
8
+
9
+ Usage:
10
+ python tools/verify_test_suite_count.py --check [--repo ROOT]
11
+ python tools/verify_test_suite_count.py --fix [--dry-run] [--repo ROOT]
12
+
13
+ Modes are mutually exclusive; if neither is specified, defaults to --check.
14
+ Idempotent: running --fix twice produces identical results.
15
+ """
16
+
17
+ import argparse
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Tuple
23
+
24
+
25
+ def count_git_files(*patterns: str) -> int:
26
+ """Count files matching patterns using git ls-files.
27
+
28
+ Omits untracked files; uses git to ensure we count only tracked files.
29
+ """
30
+ count = 0
31
+ for pattern in patterns:
32
+ try:
33
+ result = subprocess.run(
34
+ ["git", "ls-files", pattern],
35
+ capture_output=True,
36
+ text=True,
37
+ check=True,
38
+ timeout=10,
39
+ )
40
+ count += len([line for line in result.stdout.strip().split("\n") if line])
41
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
42
+ pass
43
+ return count
44
+
45
+
46
+ def get_actual_counts(repo_root: Path) -> Tuple[int, int, int]:
47
+ """Get actual test suite counts from disk.
48
+
49
+ Returns: (node_count, shell_count, python_count)
50
+ """
51
+ node_count = count_git_files("tests/*.test.mjs")
52
+ shell_count = count_git_files("tests/*.test.sh", "tests/test_*.sh", "tests/test-*.sh")
53
+ python_count = count_git_files("tests/test_*.py")
54
+
55
+ return node_count, shell_count, python_count
56
+
57
+
58
+ def get_documented_counts(claudemd_path: Path) -> Tuple[int, int, int]:
59
+ """Extract documented counts from tests/CLAUDE.md.
60
+
61
+ Returns: (node_count, shell_count, python_count) or raises ValueError if not found.
62
+ """
63
+ content = claudemd_path.read_text(encoding="utf-8")
64
+
65
+ # Match "**<Type> (N suites?)**:" patterns
66
+ node_match = re.search(r"\*\*Node \((\d+) suites?\)\*\*:", content)
67
+ shell_match = re.search(r"\*\*Shell \((\d+) suites?\)\*\*:", content)
68
+ python_match = re.search(r"\*\*Python \((\d+) suites?\)\*\*:", content)
69
+
70
+ if not (node_match and shell_match and python_match):
71
+ raise ValueError(
72
+ "Could not find one or more test suite count lines in tests/CLAUDE.md. "
73
+ "Expected: **Node (N suites)**: **Shell (N suites)**: **Python (N suites):**"
74
+ )
75
+
76
+ return (
77
+ int(node_match.group(1)),
78
+ int(shell_match.group(1)),
79
+ int(python_match.group(1)),
80
+ )
81
+
82
+
83
+ def check_mode(claudemd_path: Path) -> int:
84
+ """Verify counts match. Exit 0 if clean, 1 if drift detected.
85
+
86
+ Args:
87
+ claudemd_path: Path to tests/CLAUDE.md
88
+
89
+ Returns:
90
+ 0 if counts match, 1 if drift detected
91
+ """
92
+ try:
93
+ documented = get_documented_counts(claudemd_path)
94
+ actual = get_actual_counts(claudemd_path.parent.parent)
95
+
96
+ if documented == actual:
97
+ print("[OK] Test suite counts match")
98
+ return 0
99
+
100
+ doc_node, doc_shell, doc_python = documented
101
+ act_node, act_shell, act_python = actual
102
+
103
+ print("[DRIFT] Test suite count mismatch:")
104
+ if doc_node != act_node:
105
+ print(f" Node: CLAUDE.md says {doc_node}, actual is {act_node}")
106
+ if doc_shell != act_shell:
107
+ print(f" Shell: CLAUDE.md says {doc_shell}, actual is {act_shell}")
108
+ if doc_python != act_python:
109
+ print(f" Python: CLAUDE.md says {doc_python}, actual is {act_python}")
110
+ print(f"\nRun: python tools/verify_test_suite_count.py --fix")
111
+ return 1
112
+ except ValueError as e:
113
+ print(f"[ERROR] {e}", file=sys.stderr)
114
+ return 2
115
+
116
+
117
+ def fix_mode(claudemd_path: Path, dry_run: bool = False) -> int:
118
+ """Auto-rewrite counts in tests/CLAUDE.md to match actual files.
119
+
120
+ Args:
121
+ claudemd_path: Path to tests/CLAUDE.md
122
+ dry_run: If True, show what would change but don't write
123
+
124
+ Returns:
125
+ 0 if successful (or dry_run shows what would change), 1 on error
126
+ """
127
+ try:
128
+ actual = get_actual_counts(claudemd_path.parent.parent)
129
+ act_node, act_shell, act_python = actual
130
+
131
+ content = claudemd_path.read_text(encoding="utf-8")
132
+ original_content = content
133
+
134
+ # Rewrite count patterns
135
+ content = re.sub(
136
+ r"\*\*Node \(\d+ suites?\)\*\*:",
137
+ f"**Node ({act_node} suites)**:",
138
+ content,
139
+ )
140
+ content = re.sub(
141
+ r"\*\*Shell \(\d+ suites?\)\*\*:",
142
+ f"**Shell ({act_shell} suites)**:",
143
+ content,
144
+ )
145
+ content = re.sub(
146
+ r"\*\*Python \(\d+ suites?\)\*\*:",
147
+ f"**Python ({act_python} suites)**:",
148
+ content,
149
+ )
150
+
151
+ if content == original_content:
152
+ print("[OK] Counts already match, no changes needed")
153
+ return 0
154
+
155
+ if dry_run:
156
+ print(f"[DRY-RUN] Would update counts:")
157
+ print(f" Node: {re.search(r'Node \\((\\d+)', original_content).group(1)} → {act_node}")
158
+ print(f" Shell: {re.search(r'Shell \\((\\d+)', original_content).group(1)} → {act_shell}")
159
+ print(f" Python: {re.search(r'Python \\((\\d+)', original_content).group(1)} → {act_python}")
160
+ print()
161
+ print("Run without --dry-run to apply changes.")
162
+ return 0
163
+
164
+ # Write the updated content
165
+ claudemd_path.write_text(content, encoding="utf-8")
166
+
167
+ doc_node, doc_shell, doc_python = get_documented_counts(claudemd_path)
168
+ print(f"[FIXED] Updated tests/CLAUDE.md:")
169
+ print(f" Node: {doc_node} suites")
170
+ print(f" Shell: {doc_shell} suites")
171
+ print(f" Python: {doc_python} suites")
172
+ return 0
173
+ except ValueError as e:
174
+ print(f"[ERROR] {e}", file=sys.stderr)
175
+ return 1
176
+
177
+
178
+ def main():
179
+ """Main entry point."""
180
+ parser = argparse.ArgumentParser(
181
+ description=__doc__,
182
+ formatter_class=argparse.RawDescriptionHelpFormatter,
183
+ )
184
+
185
+ parser.add_argument(
186
+ "--check",
187
+ action="store_true",
188
+ help="Verify counts match (exit 1 if drift); default if neither --check nor --fix specified",
189
+ )
190
+ parser.add_argument(
191
+ "--fix",
192
+ action="store_true",
193
+ help="Auto-rewrite counts to match actual files",
194
+ )
195
+ parser.add_argument(
196
+ "--dry-run",
197
+ action="store_true",
198
+ help="With --fix: show what would change but don't write (implies --fix)",
199
+ )
200
+ parser.add_argument(
201
+ "--claudemd",
202
+ type=Path,
203
+ default=None,
204
+ help="Path to tests/CLAUDE.md (default: auto-detect from repo root)",
205
+ )
206
+ parser.add_argument(
207
+ "--repo",
208
+ type=Path,
209
+ default=None,
210
+ help="Repository root (default: current directory)",
211
+ )
212
+
213
+ args = parser.parse_args()
214
+
215
+ # Validate mutually exclusive modes
216
+ if args.check and args.fix:
217
+ print("[ERROR] --check and --fix are mutually exclusive", file=sys.stderr)
218
+ return 1
219
+
220
+ # --dry-run implies --fix
221
+ if args.dry_run and not args.fix:
222
+ args.fix = True
223
+
224
+ # Default to --check if neither specified
225
+ if not args.check and not args.fix:
226
+ args.check = True
227
+
228
+ # Determine repo root
229
+ repo_root = args.repo or Path.cwd()
230
+ repo_root = repo_root.resolve()
231
+
232
+ # Determine CLAUDE.md path
233
+ if args.claudemd:
234
+ claudemd_path = args.claudemd.resolve()
235
+ else:
236
+ claudemd_path = repo_root / "tests" / "CLAUDE.md"
237
+
238
+ if not claudemd_path.exists():
239
+ print(f"[ERROR] {claudemd_path} not found", file=sys.stderr)
240
+ return 2
241
+
242
+ # Run the selected mode
243
+ if args.check:
244
+ return check_mode(claudemd_path)
245
+ else:
246
+ return fix_mode(claudemd_path, dry_run=args.dry_run)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())