@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.
- package/CHANGELOG.md +24 -0
- package/README.md +16 -9
- package/bin/cli.js +102 -32
- package/daemons/run-watchdog.sh +6 -2
- package/docs/CONFIGURE.md +31 -20
- package/docs/FIRST-WAVE.md +29 -9
- package/docs/INSTALL.md +77 -12
- package/docs/PORTING.md +4 -0
- package/docs/README.md +3 -3
- package/docs/THE-AESOP-HYPOTHESIS.md +23 -7
- package/package.json +1 -1
- package/tools/doctor.js +124 -12
- package/tools/reproduce.js +11 -2
- package/tools/self_stats.py +61 -6
- package/tools/verify_test_suite_count.py +250 -0
- package/tools/wave_manifest_lint.py +485 -0
- package/tools/wave_scorecard.py +430 -0
- package/ui/config.py +1 -1
- package/ui/cost.py +176 -3
- package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
- package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
- package/ui/web/dist/index.html +2 -2
|
@@ -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()
|