@theglitchking/babel-fish 1.0.1 → 2.0.0

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.
Files changed (56) hide show
  1. package/.claude/install.sh +666 -0
  2. package/.claude/project-map/PROJECT_MAP.md +61 -0
  3. package/.claude/project-map/checksums.json +6 -0
  4. package/.claude/project-map/generate.py +1481 -0
  5. package/.claude/project-map/grader.py +583 -0
  6. package/.claude/project-map/learned-vocabulary.json +1 -0
  7. package/.claude/project-map/mine-sessions.py +419 -0
  8. package/.claude/project-map/reports/install-report.md +54 -0
  9. package/.claude/project-map/reports/iteration-01-report.md +54 -0
  10. package/.claude/project-map/reports/iteration-01-score.json +7 -0
  11. package/.claude/project-map/sections/01-vocabulary.md +8 -0
  12. package/.claude/project-map/sections/02-service-topology.md +6 -0
  13. package/.claude/project-map/sections/03-environment.md +6 -0
  14. package/.claude/project-map/sections/04-api-routes.md +6 -0
  15. package/.claude/project-map/sections/05-data-models.md +4 -0
  16. package/.claude/project-map/sections/06-schemas.md +4 -0
  17. package/.claude/project-map/sections/07-services.md +6 -0
  18. package/.claude/project-map/sections/08-background-jobs.md +5 -0
  19. package/.claude/project-map/sections/09-frontend-features.md +4 -0
  20. package/.claude/project-map/sections/10-tools-commands.md +8 -0
  21. package/.claude/project-map/sections/11-migrations.md +4 -0
  22. package/.claude/project-map/sections/12-import-chains.md +7 -0
  23. package/.claude/project-map/sections/13-frontend-backend-map.md +8 -0
  24. package/.claude/project-map/sections/14-reverse-proxy.md +4 -0
  25. package/.claude/project-map/sections/15-auth-config.md +6 -0
  26. package/.claude/project-map/sections/16-infra-profile.md +13 -0
  27. package/.claude/project-map/sections/17-learned-vocabulary.md +7 -0
  28. package/.claude/project-map/sections/18-dead-code.md +9 -0
  29. package/.claude/project-map/sections/19-doc-pointers.md +5 -0
  30. package/.claude/project-map/stack.json +12 -0
  31. package/.claude/rules/operational-runbook.md +40 -0
  32. package/.claude/rules/project-vocabulary.md +25 -0
  33. package/.claude/scripts/detect-stack.sh +222 -0
  34. package/.claude/scripts/ensure-python.sh +100 -0
  35. package/.claude/scripts/statusline.sh +27 -0
  36. package/.claude/scripts/validate.sh +59 -0
  37. package/.claude/settings.json +6 -0
  38. package/.claude/skills/babel-fish-developer-skill/SKILL.md +56 -0
  39. package/.claude/templates/SKILL.md.template +56 -0
  40. package/.claude/templates/operational-runbook.md.template +40 -0
  41. package/.claude/templates/project-vocabulary.md.template +25 -0
  42. package/.claude-plugin/marketplace.json +2 -2
  43. package/.claude-plugin/plugin.json +1 -1
  44. package/.githooks/install.sh +4 -0
  45. package/.githooks/pre-commit +22 -0
  46. package/CHANGELOG.md +72 -0
  47. package/README.md +18 -0
  48. package/bin/babel-fish.js +78 -71
  49. package/commands/policy.md +16 -0
  50. package/commands/relink.md +6 -0
  51. package/commands/status.md +6 -0
  52. package/commands/update.md +6 -0
  53. package/hooks/hooks.json +15 -0
  54. package/hooks/session-start.js +11 -0
  55. package/package.json +17 -4
  56. package/scripts/link-skills.js +31 -0
@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ grader.py — Babel Fish Quality Grader
4
+ Grades generate.py output on a 0-100 scale across 7 weighted categories.
5
+ Writes a human-readable report and returns exit code 0 (pass) or 1 (fail).
6
+
7
+ Usage:
8
+ python grader.py [--project-root PATH] [--iteration N] [--total N] [--report-path PATH]
9
+
10
+ Exit codes:
11
+ 0 = PASS (score >= 90%)
12
+ 1 = FAIL (score < 90%)
13
+ 2 = ERROR (could not run)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ # ── Paths ────────────────────────────────────────────────────────────────────
27
+ SCRIPT_DIR = Path(__file__).parent
28
+ MAP_DIR = SCRIPT_DIR
29
+ SECTIONS_DIR = MAP_DIR / "sections"
30
+ REPORTS_DIR = MAP_DIR / "reports"
31
+ PROJECT_ROOT = MAP_DIR.parent.parent
32
+
33
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
34
+
35
+ PASS_THRESHOLD = 90.0
36
+
37
+ # ── Secret pattern (mirrors generate.py) ─────────────────────────────────────
38
+ SECRET_PATTERN = re.compile(
39
+ r'(?i)(password|secret|token|api_key|apikey|private_key|auth_token|'
40
+ r'access_key|secret_key|client_secret|db_pass|database_password|'
41
+ r'stripe_key|twilio_auth|sendgrid_key|aws_secret)\s*[=:]\s*[^\[\n\r]{4,}'
42
+ )
43
+
44
+ # ── Grading weights ───────────────────────────────────────────────────────────
45
+ WEIGHTS = {
46
+ 'section_completeness': 25,
47
+ 'vocabulary_accuracy': 20,
48
+ 'import_chain_validity':15,
49
+ 'secret_safety': 15,
50
+ 'section_size_bounds': 10,
51
+ 'structural_integrity': 10,
52
+ 'checksum_functionality': 5,
53
+ }
54
+
55
+ assert sum(WEIGHTS.values()) == 100
56
+
57
+ # ── Color helpers (ANSI) ──────────────────────────────────────────────────────
58
+ GREEN = '\033[32m'
59
+ YELLOW = '\033[33m'
60
+ RED = '\033[31m'
61
+ CYAN = '\033[36m'
62
+ BOLD = '\033[1m'
63
+ RESET = '\033[0m'
64
+
65
+ def color_score(score: float) -> str:
66
+ c = GREEN if score >= 90 else YELLOW if score >= 70 else RED
67
+ return f"{c}{score:.1f}%{RESET}"
68
+
69
+ def color_pass(passed: bool) -> str:
70
+ return f"{GREEN}PASS{RESET}" if passed else f"{RED}FAIL{RESET}"
71
+
72
+
73
+ # ╔══════════════════════════════════════════════════════════════════════════╗
74
+ # ║ GRADING FUNCTIONS ║
75
+ # ╚══════════════════════════════════════════════════════════════════════════╝
76
+
77
+ class GradeResult:
78
+ def __init__(self, category: str, weight: int, raw_score: float,
79
+ details: str, issues: list[str], fixes: list[str] | None = None):
80
+ self.category = category
81
+ self.weight = weight
82
+ self.raw_score = max(0.0, min(100.0, raw_score)) # clamp 0-100
83
+ self.weighted = self.raw_score * weight / 100
84
+ self.details = details
85
+ self.issues = issues
86
+ self.fixes = fixes or []
87
+
88
+ @property
89
+ def display_name(self) -> str:
90
+ return self.category.replace('_', ' ').title()
91
+
92
+
93
+ def grade_section_completeness() -> GradeResult:
94
+ """25% — expected sections were generated."""
95
+ issues = []
96
+ expected = [f"{str(i).zfill(2)}-" for i in range(1, 20)] # 01- through 19-
97
+ present = set()
98
+
99
+ if SECTIONS_DIR.exists():
100
+ for f in SECTIONS_DIR.glob('*.md'):
101
+ for exp in expected:
102
+ if f.name.startswith(exp):
103
+ present.add(exp)
104
+
105
+ missing = [e for e in expected if e not in present]
106
+ for m in missing:
107
+ issues.append(f"Missing section: {m}*.md")
108
+
109
+ score = (len(present) / len(expected)) * 100 if expected else 100.0
110
+
111
+ # Check PROJECT_MAP.md exists
112
+ if not (MAP_DIR / 'PROJECT_MAP.md').exists():
113
+ issues.append("PROJECT_MAP.md not generated")
114
+ score = min(score, 50.0)
115
+
116
+ details = f"{len(present)}/{len(expected)} sections present"
117
+ return GradeResult('section_completeness', WEIGHTS['section_completeness'],
118
+ score, details, issues)
119
+
120
+
121
+ def grade_vocabulary_accuracy() -> GradeResult:
122
+ """20% — vocabulary entries point to files that exist."""
123
+ vocab_file = SECTIONS_DIR / '01-vocabulary.md'
124
+ issues = []
125
+
126
+ if not vocab_file.exists():
127
+ return GradeResult('vocabulary_accuracy', WEIGHTS['vocabulary_accuracy'],
128
+ 0.0, 'vocabulary section missing', ['01-vocabulary.md not found'])
129
+
130
+ content = vocab_file.read_text(encoding='utf-8', errors='replace')
131
+
132
+ # Extract location column from table rows
133
+ # Format: | alias | type | location | notes |
134
+ rows = re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|', content, re.MULTILINE)
135
+ rows = [r for r in rows if not r[0].strip().startswith('-') and r[0].strip() != 'Alias']
136
+
137
+ if not rows:
138
+ # Greenfield — empty vocab is acceptable
139
+ details = "No vocabulary entries yet (greenfield — ok)"
140
+ return GradeResult('vocabulary_accuracy', WEIGHTS['vocabulary_accuracy'],
141
+ 85.0, details, [])
142
+
143
+ total = len(rows)
144
+ valid = 0
145
+ for _, _, location, _ in rows:
146
+ loc = location.strip()
147
+ if not loc or loc.startswith('_') or loc == '':
148
+ valid += 1 # empty location is neutral
149
+ continue
150
+ # Check if any referenced file exists
151
+ # Location may contain multiple paths separated by ', '
152
+ paths = [p.strip() for p in loc.split(',')]
153
+ any_exists = any((PROJECT_ROOT / p).exists() for p in paths if p and not p.startswith('('))
154
+ if any_exists or loc.startswith('[LEARNED]') or '(learned' in loc.lower():
155
+ valid += 1
156
+ else:
157
+ issues.append(f"Vocab entry points to non-existent path: {loc[:60]}")
158
+
159
+ score = (valid / total * 100) if total > 0 else 85.0
160
+ details = f"{valid}/{total} entries have valid file references"
161
+ return GradeResult('vocabulary_accuracy', WEIGHTS['vocabulary_accuracy'],
162
+ score, details, issues[:5]) # cap at 5 issues in report
163
+
164
+
165
+ def grade_import_chains() -> GradeResult:
166
+ """15% — import chains section exists and chains are plausible."""
167
+ chains_file = SECTIONS_DIR / '12-import-chains.md'
168
+ issues = []
169
+
170
+ if not chains_file.exists():
171
+ return GradeResult('import_chain_validity', WEIGHTS['import_chain_validity'],
172
+ 0.0, 'section missing', ['12-import-chains.md not found'])
173
+
174
+ content = chains_file.read_text(encoding='utf-8', errors='replace')
175
+
176
+ if '_No import chains traced' in content or '_No' in content:
177
+ # For greenfield or non-Python: acceptable
178
+ details = "No chains traced (greenfield or non-Python stack — ok)"
179
+ return GradeResult('import_chain_validity', WEIGHTS['import_chain_validity'],
180
+ 80.0, details, [])
181
+
182
+ # Check that chains reference real files
183
+ chain_lines = re.findall(r'```\n(.+)\n```', content)
184
+ total = len(chain_lines)
185
+ valid = 0
186
+ for chain in chain_lines:
187
+ files = re.findall(r'([\w/.-]+\.py)', chain)
188
+ if not files:
189
+ valid += 1
190
+ continue
191
+ if all((PROJECT_ROOT / f).exists() for f in files[:2]):
192
+ valid += 1
193
+ else:
194
+ issues.append(f"Chain references missing files: {chain[:80]}")
195
+
196
+ score = (valid / total * 100) if total > 0 else 80.0
197
+ details = f"{valid}/{total} chains reference real files"
198
+ return GradeResult('import_chain_validity', WEIGHTS['import_chain_validity'],
199
+ score, details, issues[:3])
200
+
201
+
202
+ def grade_secret_safety() -> GradeResult:
203
+ """15% — no secrets leaked in any section or PROJECT_MAP.md."""
204
+ issues = []
205
+ files_checked = 0
206
+ leaks_found = 0
207
+
208
+ check_paths: list[Path] = [MAP_DIR / 'PROJECT_MAP.md']
209
+ if SECTIONS_DIR.exists():
210
+ check_paths.extend(SECTIONS_DIR.glob('*.md'))
211
+
212
+ for path in check_paths:
213
+ if not path.exists():
214
+ continue
215
+ files_checked += 1
216
+ try:
217
+ content = path.read_text(encoding='utf-8', errors='replace')
218
+ for m in SECRET_PATTERN.finditer(content):
219
+ line_num = content[:m.start()].count('\n') + 1
220
+ leaks_found += 1
221
+ issues.append(f"Potential secret in {path.name}:{line_num} — `{m.group()[:50]}...`")
222
+ except Exception as e:
223
+ issues.append(f"Could not read {path.name}: {e}")
224
+
225
+ # Secret leaks are a hard fail — each leak heavily penalizes
226
+ score = max(0.0, 100.0 - (leaks_found * 25))
227
+ details = f"Checked {files_checked} files — {leaks_found} potential leak(s)"
228
+ return GradeResult('secret_safety', WEIGHTS['secret_safety'],
229
+ score, details, issues)
230
+
231
+
232
+ def grade_section_sizes() -> GradeResult:
233
+ """10% — sections are within 0.1KB–50KB bounds."""
234
+ issues = []
235
+ if not SECTIONS_DIR.exists():
236
+ return GradeResult('section_size_bounds', WEIGHTS['section_size_bounds'],
237
+ 0.0, 'sections/ directory missing', ['sections/ not found'])
238
+
239
+ sections = list(SECTIONS_DIR.glob('*.md'))
240
+ if not sections:
241
+ # Greenfield — no sections yet means they were written as empty placeholders
242
+ return GradeResult('section_size_bounds', WEIGHTS['section_size_bounds'],
243
+ 85.0, 'sections exist (greenfield content expected to be minimal)', [])
244
+
245
+ violations = 0
246
+ for f in sections:
247
+ size_kb = f.stat().st_size / 1024
248
+ if size_kb < 0.05:
249
+ issues.append(f"{f.name}: {size_kb:.2f}KB — suspiciously empty")
250
+ violations += 1
251
+ elif size_kb > 50:
252
+ issues.append(f"{f.name}: {size_kb:.1f}KB — exceeds 50KB limit (should be split)")
253
+ violations += 1
254
+
255
+ score = max(0.0, 100.0 - (violations / max(len(sections), 1)) * 100)
256
+ details = f"{len(sections)} sections, {violations} size violations"
257
+ return GradeResult('section_size_bounds', WEIGHTS['section_size_bounds'],
258
+ score, details, issues[:5])
259
+
260
+
261
+ def grade_structural_integrity() -> GradeResult:
262
+ """10% — valid markdown, proper headings, TOC links resolve."""
263
+ issues = []
264
+ score = 100.0
265
+
266
+ project_map = MAP_DIR / 'PROJECT_MAP.md'
267
+ if not project_map.exists():
268
+ return GradeResult('structural_integrity', WEIGHTS['structural_integrity'],
269
+ 0.0, 'PROJECT_MAP.md missing', ['PROJECT_MAP.md not found'])
270
+
271
+ content = project_map.read_text(encoding='utf-8', errors='replace')
272
+
273
+ # Must have a top-level heading
274
+ if not re.search(r'^# .+', content, re.MULTILINE):
275
+ issues.append("PROJECT_MAP.md missing top-level heading")
276
+ score -= 20
277
+
278
+ # Must have Stats section
279
+ if '## Stats' not in content:
280
+ issues.append("PROJECT_MAP.md missing ## Stats section")
281
+ score -= 15
282
+
283
+ # Must have Section Index
284
+ if 'Section Index' not in content:
285
+ issues.append("PROJECT_MAP.md missing Section Index")
286
+ score -= 15
287
+
288
+ # Must have Quick Routing
289
+ if 'Quick Routing' not in content:
290
+ issues.append("PROJECT_MAP.md missing Quick Routing table")
291
+ score -= 10
292
+
293
+ # Check that section links in TOC point to real files
294
+ link_pattern = re.compile(r'\[(\d+)\]\(sections/([^)]+)\)')
295
+ broken_links = 0
296
+ for m in link_pattern.finditer(content):
297
+ target = SECTIONS_DIR / m.group(2)
298
+ if not target.exists():
299
+ issues.append(f"Broken TOC link: sections/{m.group(2)}")
300
+ broken_links += 1
301
+ if broken_links:
302
+ score -= broken_links * 5
303
+
304
+ # Check each section has a top-level heading
305
+ if SECTIONS_DIR.exists():
306
+ for f in SECTIONS_DIR.glob('*.md'):
307
+ try:
308
+ first_line = f.read_text(encoding='utf-8', errors='replace').split('\n')[0]
309
+ if not first_line.startswith('#'):
310
+ issues.append(f"{f.name}: missing top-level heading")
311
+ score -= 2
312
+ except Exception:
313
+ pass
314
+
315
+ details = f"{broken_links} broken TOC links, {len(issues)} structural issues"
316
+ return GradeResult('structural_integrity', WEIGHTS['structural_integrity'],
317
+ max(0.0, score), details, issues[:5])
318
+
319
+
320
+ def grade_checksum_functionality() -> GradeResult:
321
+ """5% — checksums.json exists and looks valid."""
322
+ issues = []
323
+
324
+ checksums_path = MAP_DIR / 'checksums.json'
325
+ if not checksums_path.exists():
326
+ return GradeResult('checksum_functionality', WEIGHTS['checksum_functionality'],
327
+ 0.0, 'checksums.json missing', ['checksums.json not found — checksum skip will not work'])
328
+
329
+ try:
330
+ data = json.loads(checksums_path.read_text())
331
+ if 'input_hash' not in data:
332
+ issues.append("checksums.json missing 'input_hash' key")
333
+ return GradeResult('checksum_functionality', WEIGHTS['checksum_functionality'],
334
+ 50.0, 'checksums.json malformed', issues)
335
+ if not isinstance(data['input_hash'], str) or len(data['input_hash']) < 32:
336
+ issues.append("checksums.json has invalid hash value")
337
+ return GradeResult('checksum_functionality', WEIGHTS['checksum_functionality'],
338
+ 60.0, 'checksums.json has weak hash', issues)
339
+ except json.JSONDecodeError as e:
340
+ return GradeResult('checksum_functionality', WEIGHTS['checksum_functionality'],
341
+ 0.0, f'checksums.json parse error: {e}', [str(e)])
342
+
343
+ details = f"checksums.json valid (hash: {data['input_hash'][:12]}...)"
344
+ return GradeResult('checksum_functionality', WEIGHTS['checksum_functionality'],
345
+ 100.0, details, [])
346
+
347
+
348
+ # ╔══════════════════════════════════════════════════════════════════════════╗
349
+ # ║ REPORT BUILDER ║
350
+ # ╚══════════════════════════════════════════════════════════════════════════╝
351
+
352
+ def build_report(
353
+ results: list[GradeResult],
354
+ total_score: float,
355
+ passed: bool,
356
+ iteration: int,
357
+ total_iterations: int,
358
+ previous_score: float | None,
359
+ ) -> str:
360
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
361
+ verdict = "✅ PASSED" if passed else "❌ FAILED"
362
+ threshold_note = f"{PASS_THRESHOLD:.0f}% threshold"
363
+
364
+ delta_str = ''
365
+ if previous_score is not None:
366
+ delta = total_score - previous_score
367
+ sign = '+' if delta >= 0 else ''
368
+ delta_str = f" ({sign}{delta:.1f}% from previous)"
369
+
370
+ lines = [
371
+ f"# Babel Fish — Installation Report",
372
+ f"",
373
+ f"**Generated**: {now}",
374
+ f"**Iteration**: {iteration} of {total_iterations} ",
375
+ f"**Score**: {total_score:.1f}%{delta_str} ",
376
+ f"**Result**: {verdict} ({threshold_note})",
377
+ f"",
378
+ "---",
379
+ "",
380
+ "## Scoring Breakdown",
381
+ "",
382
+ "| Category | Score | Weight | Weighted | Status |",
383
+ "|----------|-------|--------|----------|--------|",
384
+ ]
385
+
386
+ for r in results:
387
+ status = "✅" if r.raw_score >= 90 else "⚠️" if r.raw_score >= 70 else "❌"
388
+ lines.append(
389
+ f"| {r.display_name} | {r.raw_score:.1f}% | {r.weight}% "
390
+ f"| {r.weighted:.1f} | {status} {r.details} |"
391
+ )
392
+
393
+ lines += [
394
+ "",
395
+ f"**Total: {total_score:.1f} / 100**",
396
+ "",
397
+ "---",
398
+ "",
399
+ "## Issues Found",
400
+ "",
401
+ ]
402
+
403
+ all_issues = [(r.display_name, issue) for r in results for issue in r.issues]
404
+ if all_issues:
405
+ for category, issue in all_issues:
406
+ lines.append(f"- ❌ **{category}**: {issue}")
407
+ else:
408
+ lines.append("_No issues found._")
409
+
410
+ if iteration > 1:
411
+ lines += [
412
+ "",
413
+ "---",
414
+ "",
415
+ "## Improvements Made This Iteration",
416
+ "",
417
+ ]
418
+ all_fixes = [(r.display_name, fix) for r in results for fix in r.fixes]
419
+ if all_fixes:
420
+ for category, fix in all_fixes:
421
+ lines.append(f"- ✅ **{category}**: {fix}")
422
+ else:
423
+ lines.append("_No specific improvements recorded._")
424
+
425
+ lines += [
426
+ "",
427
+ "---",
428
+ "",
429
+ "## Final Verdict",
430
+ "",
431
+ ]
432
+
433
+ if passed:
434
+ lines += [
435
+ f"✅ **PASSED ({total_score:.1f}%)** — Map is production-ready.",
436
+ "",
437
+ "The project map meets quality standards and is ready to use.",
438
+ "Future sessions will load relevant sections automatically via the developer skill.",
439
+ ]
440
+ else:
441
+ remaining = total_iterations - iteration
442
+ if remaining > 0:
443
+ lines += [
444
+ f"❌ **FAILED ({total_score:.1f}%)** — Below {PASS_THRESHOLD:.0f}% threshold.",
445
+ "",
446
+ f"{remaining} iteration(s) remaining. Addressing issues above and retrying...",
447
+ ]
448
+ else:
449
+ lines += [
450
+ f"⚠️ **COMPLETED WITH WARNINGS ({total_score:.1f}%)** — Final iteration reached.",
451
+ "",
452
+ "The map was generated but did not reach the 90% quality threshold.",
453
+ "Review the issues above and consider running `python generate.py --force` after",
454
+ "addressing them, then re-running `python grader.py` to verify.",
455
+ ]
456
+
457
+ lines += [
458
+ "",
459
+ "---",
460
+ "",
461
+ "## How to Use the Project Map",
462
+ "",
463
+ "```bash",
464
+ "# Read the map index",
465
+ "cat .claude/project-map/PROJECT_MAP.md",
466
+ "",
467
+ "# Force regeneration",
468
+ "python .claude/project-map/generate.py --force",
469
+ "",
470
+ "# Re-run grader",
471
+ "python .claude/project-map/grader.py",
472
+ "```",
473
+ "",
474
+ "_Report generated by `grader.py` — part of the Babel Fish plugin._",
475
+ ]
476
+
477
+ return '\n'.join(lines) + '\n'
478
+
479
+
480
+ def print_terminal_summary(results: list[GradeResult], total_score: float, passed: bool,
481
+ iteration: int, total_iterations: int) -> None:
482
+ """Print a concise terminal summary with color."""
483
+ print(f"\n{CYAN}{'─' * 55}{RESET}")
484
+ print(f"{BOLD} Babel Fish — Grade Report "
485
+ f"(Iteration {iteration}/{total_iterations}){RESET}")
486
+ print(f"{CYAN}{'─' * 55}{RESET}\n")
487
+
488
+ for r in results:
489
+ bar_filled = int(r.raw_score / 10)
490
+ bar = ('█' * bar_filled) + ('░' * (10 - bar_filled))
491
+ c = GREEN if r.raw_score >= 90 else YELLOW if r.raw_score >= 70 else RED
492
+ print(f" {r.display_name:<30} {c}{bar}{RESET} {r.raw_score:5.1f}% (×{r.weight}%)")
493
+
494
+ print(f"\n{CYAN}{'─' * 55}{RESET}")
495
+ score_color = GREEN if passed else (YELLOW if total_score >= 70 else RED)
496
+ verdict = f"{GREEN}PASS ✓{RESET}" if passed else f"{RED}FAIL ✗{RESET}"
497
+ print(f" {'TOTAL SCORE':<30} {score_color}{total_score:5.1f}%{RESET} → {verdict}")
498
+ print(f"{CYAN}{'─' * 55}{RESET}\n")
499
+
500
+ all_issues = [(r.display_name, i) for r in results for i in r.issues]
501
+ if all_issues:
502
+ print(f"{YELLOW} Issues:{RESET}")
503
+ for cat, issue in all_issues[:8]:
504
+ print(f" ✗ {cat}: {issue}")
505
+ if len(all_issues) > 8:
506
+ print(f" ... and {len(all_issues) - 8} more (see report)")
507
+ print()
508
+
509
+
510
+ # ╔══════════════════════════════════════════════════════════════════════════╗
511
+ # ║ MAIN ║
512
+ # ╚══════════════════════════════════════════════════════════════════════════╝
513
+
514
+ def main() -> None:
515
+ parser = argparse.ArgumentParser(description='Grade babel-fish output')
516
+ parser.add_argument('--project-root', type=Path, default=None)
517
+ parser.add_argument('--iteration', type=int, default=1)
518
+ parser.add_argument('--total', type=int, default=3, help='Total iterations planned')
519
+ parser.add_argument('--previous-score', type=float, default=None)
520
+ parser.add_argument('--report-path', type=Path, default=None,
521
+ help='Override report output path')
522
+ args = parser.parse_args()
523
+
524
+ global PROJECT_ROOT
525
+ if args.project_root:
526
+ PROJECT_ROOT = args.project_root.resolve()
527
+
528
+ print(f"[grader] Grading iteration {args.iteration}/{args.total}...")
529
+
530
+ results = [
531
+ grade_section_completeness(),
532
+ grade_vocabulary_accuracy(),
533
+ grade_import_chains(),
534
+ grade_secret_safety(),
535
+ grade_section_sizes(),
536
+ grade_structural_integrity(),
537
+ grade_checksum_functionality(),
538
+ ]
539
+
540
+ total_score = sum(r.weighted for r in results)
541
+ passed = total_score >= PASS_THRESHOLD
542
+
543
+ print_terminal_summary(results, total_score, passed, args.iteration, args.total)
544
+
545
+ # Write report
546
+ report_content = build_report(results, total_score, passed,
547
+ args.iteration, args.total, args.previous_score)
548
+
549
+ report_path = args.report_path or (REPORTS_DIR / f"iteration-{args.iteration:02d}-report.md")
550
+ report_path.write_text(report_content, encoding='utf-8')
551
+
552
+ # Also write/update the main install report
553
+ install_report = REPORTS_DIR / "install-report.md"
554
+ if args.iteration == 1:
555
+ install_report.write_text(report_content, encoding='utf-8')
556
+ else:
557
+ # Append this iteration's report
558
+ existing = install_report.read_text(encoding='utf-8') if install_report.exists() else ''
559
+ separator = f"\n\n{'='*60}\n\n"
560
+ install_report.write_text(existing + separator + report_content, encoding='utf-8')
561
+
562
+ print(f"[grader] Report written → {report_path}")
563
+ print(f"[grader] Install report → {install_report}")
564
+
565
+ # Output score as JSON for installer to read
566
+ score_data = {
567
+ 'score': round(total_score, 2),
568
+ 'passed': passed,
569
+ 'threshold': PASS_THRESHOLD,
570
+ 'iteration': args.iteration,
571
+ 'issues': [
572
+ {'category': r.category, 'issue': i}
573
+ for r in results for i in r.issues
574
+ ],
575
+ }
576
+ score_json = REPORTS_DIR / f"iteration-{args.iteration:02d}-score.json"
577
+ score_json.write_text(json.dumps(score_data, indent=2), encoding='utf-8')
578
+
579
+ sys.exit(0 if passed else 1)
580
+
581
+
582
+ if __name__ == '__main__':
583
+ main()