@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,485 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave manifest preflight validator.
4
+
5
+ Checks: (1) file-ownership disjointness, (2) path existence,
6
+ (3) prompt sanity, (4) git history churn, (5) testCmd validation.
7
+
8
+ Exit: 0 = PASS (warnings allowed unless --strict), non-zero on FAIL or (--strict) WARN.
9
+ """
10
+
11
+ import argparse
12
+ import fnmatch
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+
22
+
23
+ class Check:
24
+ """Result of a single check."""
25
+
26
+ def __init__(self, name: str, level: str, message: str, details: Optional[Dict[str, Any]] = None):
27
+ self.name = name
28
+ self.level = level # PASS, INFO, WARN, FAIL
29
+ self.message = message
30
+ self.details = details or {}
31
+
32
+ def to_dict(self) -> Dict[str, Any]:
33
+ return {
34
+ "name": self.name,
35
+ "level": self.level,
36
+ "message": self.message,
37
+ "details": self.details
38
+ }
39
+
40
+
41
+ def check_ownership_disjointness(items: List[Dict[str, Any]], repo_root: str) -> List[Check]:
42
+ """Check 1: Ensure file ownership does not overlap across items."""
43
+ checks = []
44
+ overlaps = {}
45
+
46
+ # Build list of patterns per item (keeping patterns for overlap checking)
47
+ item_patterns = {}
48
+ for item in items:
49
+ patterns = item.get("ownsFiles", [])
50
+ item_patterns[item["slug"]] = patterns
51
+
52
+ # Check for overlaps using glob matching
53
+ def patterns_overlap(patterns_a, patterns_b):
54
+ """Check if two sets of patterns would overlap."""
55
+ for pat_a in patterns_a:
56
+ for pat_b in patterns_b:
57
+ # Check if patterns match each other
58
+ if fnmatch.fnmatch(pat_b, pat_a) or fnmatch.fnmatch(pat_a, pat_b):
59
+ return True
60
+ # Check if both are literals and equal
61
+ if pat_a == pat_b:
62
+ return True
63
+ return False
64
+
65
+ # Find overlapping items
66
+ for slug_a in item_patterns:
67
+ for slug_b in item_patterns:
68
+ if slug_a < slug_b: # Only check each pair once
69
+ patterns_a = item_patterns[slug_a]
70
+ patterns_b = item_patterns[slug_b]
71
+
72
+ if patterns_overlap(patterns_a, patterns_b):
73
+ # Find which specific patterns overlap
74
+ overlapping = []
75
+ for pat_a in patterns_a:
76
+ for pat_b in patterns_b:
77
+ if fnmatch.fnmatch(pat_b, pat_a) or fnmatch.fnmatch(pat_a, pat_b) or pat_a == pat_b:
78
+ overlapping.append(pat_b if fnmatch.fnmatch(pat_b, pat_a) else pat_a)
79
+
80
+ key = (slug_a, slug_b)
81
+ overlaps[key] = sorted(list(set(overlapping)))
82
+
83
+ if overlaps:
84
+ overlap_list = []
85
+ for (slug_a, slug_b), files in overlaps.items():
86
+ overlap_list.append({
87
+ "items": [slug_a, slug_b],
88
+ "files": files
89
+ })
90
+ checks.append(Check(
91
+ "ownership_disjointness",
92
+ "FAIL",
93
+ f"File ownership overlap detected: {len(overlap_list)} pair(s)",
94
+ {"overlaps": overlap_list}
95
+ ))
96
+ else:
97
+ checks.append(Check(
98
+ "ownership_disjointness",
99
+ "PASS",
100
+ "No file ownership overlaps"
101
+ ))
102
+
103
+ return checks
104
+
105
+
106
+ def check_path_existence(items: List[Dict[str, Any]], repo_root: str) -> List[Check]:
107
+ """Check 2: Verify ownsFiles paths exist or flag new files as INFO."""
108
+ checks = []
109
+ new_files = []
110
+ missing_files = []
111
+
112
+ for item in items:
113
+ for pattern in item.get("ownsFiles", []):
114
+ expanded = list(Path(repo_root).glob(pattern))
115
+ if expanded:
116
+ # File exists (matched by glob)
117
+ pass
118
+ else:
119
+ # Pattern didn't match - check if it's literally a new file
120
+ path = Path(repo_root) / pattern
121
+ if not path.exists():
122
+ new_files.append((item["slug"], pattern))
123
+
124
+ if new_files:
125
+ details = [{"item": slug, "file": f} for slug, f in new_files]
126
+ checks.append(Check(
127
+ "path_existence",
128
+ "INFO",
129
+ f"{len(new_files)} new file(s)",
130
+ {"new_files": details}
131
+ ))
132
+ else:
133
+ checks.append(Check(
134
+ "path_existence",
135
+ "PASS",
136
+ "All files exist or are flagged as new"
137
+ ))
138
+
139
+ return checks
140
+
141
+
142
+ def check_prompt_sanity(items: List[Dict[str, Any]]) -> List[Check]:
143
+ """Check 3: Validate prompt - non-empty, contains worktree marker, no unexpected ALLOW-NON-HAIKU."""
144
+ checks = []
145
+ issues = []
146
+
147
+ for item in items:
148
+ slug = item["slug"]
149
+ prompt = item.get("prompt", "")
150
+
151
+ # Check non-empty
152
+ if not prompt or not prompt.strip():
153
+ issues.append({
154
+ "item": slug,
155
+ "issue": "empty_prompt",
156
+ "level": "FAIL"
157
+ })
158
+ continue
159
+
160
+ # Check for worktree isolation marker
161
+ has_marker = "[ISOLATION: sibling worktree]" in prompt or "sibling worktree" in prompt
162
+ if not has_marker:
163
+ issues.append({
164
+ "item": slug,
165
+ "issue": "missing_isolation_marker",
166
+ "level": "WARN"
167
+ })
168
+
169
+ # Check for ALLOW-NON-HAIKU without explicit expectation
170
+ if "[[ALLOW-NON-HAIKU]]" in prompt:
171
+ has_allow_sonnet = "[[ALLOW-SONNET]]" in prompt
172
+ has_allow_opus = "[[ALLOW-OPUS]]" in prompt
173
+ has_explicit = has_allow_sonnet or has_allow_opus
174
+
175
+ if not has_explicit:
176
+ issues.append({
177
+ "item": slug,
178
+ "issue": "allow_non_haiku_without_explicit_expectation",
179
+ "level": "WARN"
180
+ })
181
+
182
+ if any(i["level"] == "FAIL" for i in issues):
183
+ checks.append(Check(
184
+ "prompt_sanity",
185
+ "FAIL",
186
+ "Prompt validation failed",
187
+ {"issues": issues}
188
+ ))
189
+ elif issues:
190
+ checks.append(Check(
191
+ "prompt_sanity",
192
+ "WARN",
193
+ f"{len(issues)} prompt warning(s)",
194
+ {"issues": issues}
195
+ ))
196
+ else:
197
+ checks.append(Check(
198
+ "prompt_sanity",
199
+ "PASS",
200
+ "All prompts valid"
201
+ ))
202
+
203
+ return checks
204
+
205
+
206
+ def check_git_history_churn(items: List[Dict[str, Any]], repo_root: str) -> List[Check]:
207
+ """Check 4: Flag files with recent churn (14-day history) as elevated-retry-risk."""
208
+ checks = []
209
+ warnings = []
210
+
211
+ # Try to run git log to get recent changes
212
+ try:
213
+ result = subprocess.run(
214
+ [sys.executable, "-c", "import subprocess; subprocess.run(['git', 'log', '--all', '--since=14.days'], cwd=r'{}', capture_output=True, text=True, timeout=5)".format(repo_root)],
215
+ capture_output=True,
216
+ timeout=5,
217
+ check=False,
218
+ text=True,
219
+ cwd=repo_root
220
+ )
221
+ except (subprocess.TimeoutExpired, FileNotFoundError):
222
+ # Git not available or repo not initialized - skip check
223
+ checks.append(Check(
224
+ "git_history_churn",
225
+ "PASS",
226
+ "Churn check skipped (git not available)"
227
+ ))
228
+ return checks
229
+
230
+ # Use bash/git directly to query recent changes
231
+ try:
232
+ cmd = f"cd {repo_root} && git log --all --since='14 days ago' --name-only --pretty=format: | sort | uniq -c | sort -rn"
233
+ result = subprocess.run(
234
+ ["bash", "-c", cmd],
235
+ capture_output=True,
236
+ timeout=5,
237
+ text=True,
238
+ check=False
239
+ )
240
+
241
+ # Parse git log output to count changes per file
242
+ changed_files = {}
243
+ if result.stdout:
244
+ for line in result.stdout.strip().split("\n"):
245
+ if line.strip():
246
+ parts = line.strip().split(None, 1)
247
+ if len(parts) == 2:
248
+ count = int(parts[0])
249
+ filename = parts[1]
250
+ changed_files[filename] = count
251
+
252
+ # Check if any item owns high-churn files
253
+ for item in items:
254
+ for pattern in item.get("ownsFiles", []):
255
+ # Check literal pattern
256
+ if pattern in changed_files and changed_files[pattern] > 3:
257
+ warnings.append({
258
+ "item": item["slug"],
259
+ "file": pattern,
260
+ "recent_commits": changed_files[pattern]
261
+ })
262
+
263
+ except Exception:
264
+ # Skip if git query fails
265
+ pass
266
+
267
+ if warnings:
268
+ checks.append(Check(
269
+ "git_history_churn",
270
+ "WARN",
271
+ f"{len(warnings)} file(s) with elevated churn",
272
+ {"churn_files": warnings}
273
+ ))
274
+ else:
275
+ checks.append(Check(
276
+ "git_history_churn",
277
+ "PASS",
278
+ "No high-churn files detected"
279
+ ))
280
+
281
+ return checks
282
+
283
+
284
+ def check_testcmd_validity(manifest: Dict[str, Any], repo_root: str) -> List[Check]:
285
+ """Check 5: Validate testCmd - binary on PATH or repo-relative script exists."""
286
+ checks = []
287
+ test_cmd = manifest.get("testCmd", "")
288
+
289
+ if not test_cmd:
290
+ checks.append(Check(
291
+ "testcmd_validity",
292
+ "WARN",
293
+ "No testCmd specified"
294
+ ))
295
+ return checks
296
+
297
+ # Parse first token (binary name)
298
+ tokens = test_cmd.split()
299
+ if not tokens:
300
+ checks.append(Check(
301
+ "testcmd_validity",
302
+ "WARN",
303
+ "testCmd is empty"
304
+ ))
305
+ return checks
306
+
307
+ binary = tokens[0]
308
+
309
+ # Check if it's a repo-relative script
310
+ if not binary.startswith("/") and not binary.startswith("C:"):
311
+ script_path = Path(repo_root) / binary
312
+ if script_path.exists():
313
+ checks.append(Check(
314
+ "testcmd_validity",
315
+ "PASS",
316
+ f"testCmd script exists: {binary}"
317
+ ))
318
+ return checks
319
+
320
+ # Check if binary is on PATH
321
+ found = shutil.which(binary)
322
+ if found:
323
+ checks.append(Check(
324
+ "testcmd_validity",
325
+ "PASS",
326
+ f"testCmd binary found: {binary}"
327
+ ))
328
+ else:
329
+ checks.append(Check(
330
+ "testcmd_validity",
331
+ "FAIL",
332
+ f"testCmd binary not found: {binary}"
333
+ ))
334
+
335
+ return checks
336
+
337
+
338
+ def load_manifest(manifest_path: str) -> Dict[str, Any]:
339
+ """Load wave manifest JSON."""
340
+ with open(manifest_path, "r") as f:
341
+ return json.load(f)
342
+
343
+
344
+ def run_checks(manifest: Dict[str, Any], repo_root: str) -> Tuple[List[Check], int]:
345
+ """Run all checks and return list of checks + exit code."""
346
+ checks = []
347
+
348
+ # Ensure repo_root is absolute
349
+ if not os.path.isabs(repo_root):
350
+ repo_root = os.path.abspath(repo_root)
351
+
352
+ items = manifest.get("items", [])
353
+
354
+ # Run all checks
355
+ checks.extend(check_ownership_disjointness(items, repo_root))
356
+ checks.extend(check_path_existence(items, repo_root))
357
+ checks.extend(check_prompt_sanity(items))
358
+ checks.extend(check_git_history_churn(items, repo_root))
359
+ checks.extend(check_testcmd_validity(manifest, repo_root))
360
+
361
+ # Determine exit code
362
+ has_fail = any(c.level == "FAIL" for c in checks)
363
+ has_warn = any(c.level == "WARN" for c in checks)
364
+
365
+ if has_fail:
366
+ exit_code = 1
367
+ elif has_warn:
368
+ exit_code = 0 # Warnings don't fail by default
369
+ else:
370
+ exit_code = 0
371
+
372
+ return checks, exit_code
373
+
374
+
375
+ def format_ascii_output(checks: List[Check], strict: bool = False) -> Tuple[str, int]:
376
+ """Format checks as ASCII output."""
377
+ lines = []
378
+ has_fail = False
379
+ has_warn = False
380
+
381
+ for check in checks:
382
+ level = check.level
383
+ message = check.message
384
+
385
+ # ASCII-safe output
386
+ lines.append(f"{level}: {check.name}: {message}")
387
+
388
+ if level == "FAIL":
389
+ has_fail = True
390
+ # Include details for failures
391
+ if check.details:
392
+ for key, val in check.details.items():
393
+ lines.append(f" {key}: {val}")
394
+
395
+ elif level == "WARN":
396
+ has_warn = True
397
+ # Include key details for warnings
398
+ if check.details:
399
+ for key, val in check.details.items():
400
+ if key in ["overlaps", "new_files", "churn_files", "issues"]:
401
+ if key == "issues" and isinstance(val, list):
402
+ for issue in val:
403
+ issue_str = f"{issue.get('item')}: {issue.get('issue')}"
404
+ if "allow_non_haiku" in issue.get('issue', '').lower():
405
+ issue_str += " [[ALLOW-NON-HAIKU]]"
406
+ lines.append(f" {issue_str}")
407
+ else:
408
+ lines.append(f" {key}: {val}")
409
+
410
+ # Determine exit code
411
+ exit_code = 0
412
+ if has_fail:
413
+ exit_code = 1
414
+ elif has_warn and strict:
415
+ exit_code = 1
416
+
417
+ output = "\n".join(lines) + "\n"
418
+ return output, exit_code
419
+
420
+
421
+ def format_json_output(checks: List[Check], strict: bool = False) -> Tuple[str, int]:
422
+ """Format checks as JSON output."""
423
+ has_fail = False
424
+ has_warn = False
425
+
426
+ result = {
427
+ "checks": [c.to_dict() for c in checks],
428
+ "summary": {
429
+ "total": len(checks),
430
+ "pass": sum(1 for c in checks if c.level == "PASS"),
431
+ "info": sum(1 for c in checks if c.level == "INFO"),
432
+ "warn": sum(1 for c in checks if c.level == "WARN"),
433
+ "fail": sum(1 for c in checks if c.level == "FAIL")
434
+ }
435
+ }
436
+
437
+ # Determine exit code
438
+ for check in checks:
439
+ if check.level == "FAIL":
440
+ has_fail = True
441
+ elif check.level == "WARN":
442
+ has_warn = True
443
+
444
+ exit_code = 0
445
+ if has_fail:
446
+ exit_code = 1
447
+ elif has_warn and strict:
448
+ exit_code = 1
449
+
450
+ output = json.dumps(result, indent=2) + "\n"
451
+ return output, exit_code
452
+
453
+
454
+ def main():
455
+ parser = argparse.ArgumentParser(
456
+ description="Wave manifest preflight validator"
457
+ )
458
+ parser.add_argument("manifest", help="Path to wave manifest JSON")
459
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
460
+ parser.add_argument("--strict", action="store_true", help="Exit non-zero on warnings")
461
+ parser.add_argument("--root", default=".", help="Repository root (default: current directory)")
462
+
463
+ args = parser.parse_args()
464
+
465
+ try:
466
+ manifest = load_manifest(args.manifest)
467
+ repo_root = args.root
468
+
469
+ checks, _ = run_checks(manifest, repo_root)
470
+
471
+ if args.json:
472
+ output, exit_code = format_json_output(checks, args.strict)
473
+ else:
474
+ output, exit_code = format_ascii_output(checks, args.strict)
475
+
476
+ sys.stdout.write(output)
477
+ sys.exit(exit_code)
478
+
479
+ except Exception as e:
480
+ sys.stderr.write(f"FAIL: {e}\n")
481
+ sys.exit(1)
482
+
483
+
484
+ if __name__ == "__main__":
485
+ main()