delimit-cli 4.1.0 → 4.1.2

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.
@@ -85,6 +85,18 @@ def _query_project_ledger_fallback(ledger_path: Path) -> Optional[Dict[str, Any]
85
85
  }
86
86
 
87
87
 
88
+ def run_spec_health(spec_path: str) -> Dict[str, Any]:
89
+ """Score a single OpenAPI spec on quality dimensions.
90
+
91
+ Returns overall score, letter grade, per-dimension scores,
92
+ and actionable recommendations.
93
+ """
94
+ from core.spec_health import score_spec
95
+
96
+ spec = _load_specs(spec_path)
97
+ return score_spec(spec)
98
+
99
+
88
100
  def run_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None) -> Dict[str, Any]:
89
101
  """Run the full lint pipeline: diff + policy evaluation.
90
102
 
@@ -281,6 +293,311 @@ def run_changelog(
281
293
  }
282
294
 
283
295
 
296
+ def run_changelog_from_git(
297
+ repo_path: str = ".",
298
+ version: str = "",
299
+ fmt: str = "keepachangelog",
300
+ since_tag: str = "",
301
+ include_ledger: bool = True,
302
+ output_file: str = "",
303
+ ) -> Dict[str, Any]:
304
+ """Generate a changelog from git commits and ledger items.
305
+
306
+ Reads git log since the last tag (or a specified tag), categorizes
307
+ commits by conventional-commit prefix, optionally pulls completed
308
+ ledger items, and formats as Markdown.
309
+
310
+ Works for ANY git repo, not just Delimit's own.
311
+ """
312
+ import re
313
+ import subprocess
314
+ from datetime import datetime, timezone
315
+
316
+ repo = Path(repo_path).resolve()
317
+ if not (repo / ".git").exists():
318
+ return {"error": "not_a_git_repo", "message": f"{repo} is not a git repository."}
319
+
320
+ # --- Resolve the base tag ---
321
+ if since_tag:
322
+ base_tag = since_tag
323
+ else:
324
+ try:
325
+ result = subprocess.run(
326
+ ["git", "describe", "--tags", "--abbrev=0"],
327
+ cwd=str(repo), capture_output=True, text=True, timeout=10,
328
+ )
329
+ base_tag = result.stdout.strip() if result.returncode == 0 else ""
330
+ except Exception:
331
+ base_tag = ""
332
+
333
+ # --- Get git log ---
334
+ git_log_cmd = ["git", "log", "--pretty=format:%H|%s|%an", "--no-merges"]
335
+ if base_tag:
336
+ git_log_cmd.append(f"{base_tag}..HEAD")
337
+ try:
338
+ result = subprocess.run(
339
+ git_log_cmd, cwd=str(repo), capture_output=True, text=True, timeout=30,
340
+ )
341
+ raw_lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
342
+ except Exception as e:
343
+ return {"error": "git_log_failed", "message": str(e)}
344
+
345
+ # --- Get diff stats ---
346
+ diff_stat_cmd = ["git", "diff", "--stat"]
347
+ if base_tag:
348
+ diff_stat_cmd.append(f"{base_tag}..HEAD")
349
+ else:
350
+ diff_stat_cmd.append("--cached") # fallback: staged changes
351
+ try:
352
+ stat_result = subprocess.run(
353
+ diff_stat_cmd, cwd=str(repo), capture_output=True, text=True, timeout=15,
354
+ )
355
+ stat_summary = stat_result.stdout.strip().split("\n")[-1] if stat_result.stdout.strip() else ""
356
+ except Exception:
357
+ stat_summary = ""
358
+
359
+ # --- Parse and categorize commits ---
360
+ # Conventional commit pattern: type(scope): message OR type: message
361
+ cc_pattern = re.compile(
362
+ r"^(?P<type>feat|fix|refactor|docs|test|tests|ci|chore|perf|style|build|revert)"
363
+ r"(?:\([^)]*\))?:\s*(?P<msg>.+)$",
364
+ re.IGNORECASE,
365
+ )
366
+
367
+ categories = {
368
+ "feat": [],
369
+ "fix": [],
370
+ "refactor": [],
371
+ "docs": [],
372
+ "test": [],
373
+ "ci": [],
374
+ "chore": [],
375
+ "other": [],
376
+ }
377
+
378
+ # Keyword fallback patterns for non-conventional commits
379
+ keyword_map = [
380
+ (re.compile(r"\b(add|feature|implement|new)\b", re.I), "feat"),
381
+ (re.compile(r"\b(fix|bug|patch|resolve|close)\b", re.I), "fix"),
382
+ (re.compile(r"\b(refactor|restructure|clean|simplify)\b", re.I), "refactor"),
383
+ (re.compile(r"\b(doc|readme|comment|jsdoc)\b", re.I), "docs"),
384
+ (re.compile(r"\b(test|spec|coverage|assert)\b", re.I), "test"),
385
+ (re.compile(r"\b(ci|workflow|action|pipeline|deploy)\b", re.I), "ci"),
386
+ ]
387
+
388
+ commits_parsed = []
389
+ for line in raw_lines:
390
+ parts = line.split("|", 2)
391
+ if len(parts) < 3:
392
+ continue
393
+ sha, subject, author = parts[0], parts[1], parts[2]
394
+
395
+ m = cc_pattern.match(subject)
396
+ if m:
397
+ ctype = m.group("type").lower()
398
+ if ctype in ("tests",):
399
+ ctype = "test"
400
+ msg = m.group("msg")
401
+ else:
402
+ # Keyword fallback
403
+ ctype = "other"
404
+ msg = subject
405
+ for pattern, cat in keyword_map:
406
+ if pattern.search(subject):
407
+ ctype = cat
408
+ break
409
+
410
+ bucket = ctype if ctype in categories else "other"
411
+ entry = {"sha": sha[:8], "message": msg, "author": author, "category": bucket}
412
+ categories[bucket].append(entry)
413
+ commits_parsed.append(entry)
414
+
415
+ # --- Pull completed ledger items (if requested and ledger exists) ---
416
+ ledger_items = []
417
+ if include_ledger:
418
+ try:
419
+ ledger_dir = Path.home() / ".delimit" / "ledger"
420
+ ops_file = ledger_dir / "operations.jsonl"
421
+ if ops_file.exists():
422
+ import json as _json
423
+ items_raw = []
424
+ for ln in ops_file.read_text().splitlines():
425
+ ln = ln.strip()
426
+ if ln:
427
+ try:
428
+ items_raw.append(_json.loads(ln))
429
+ except _json.JSONDecodeError:
430
+ continue
431
+
432
+ # Build current state by replaying events
433
+ state = {}
434
+ for item in items_raw:
435
+ item_id = item.get("id", "")
436
+ if item.get("type") == "update":
437
+ if item_id in state:
438
+ if "status" in item:
439
+ state[item_id]["status"] = item["status"]
440
+ elif item_id:
441
+ state[item_id] = item
442
+
443
+ # Find the timestamp of the base tag to filter ledger items
444
+ tag_dt = None
445
+ if base_tag:
446
+ try:
447
+ ts_result = subprocess.run(
448
+ ["git", "log", "-1", "--format=%aI", base_tag],
449
+ cwd=str(repo), capture_output=True, text=True, timeout=10,
450
+ )
451
+ if ts_result.returncode == 0 and ts_result.stdout.strip():
452
+ tag_dt = datetime.fromisoformat(ts_result.stdout.strip())
453
+ except Exception:
454
+ pass
455
+
456
+ for item_id, item in state.items():
457
+ if item.get("status") == "done":
458
+ created = item.get("created_at", "")
459
+ # If we have a tag datetime, only include items created after it
460
+ if tag_dt and created:
461
+ try:
462
+ # Normalize "Z" suffix to "+00:00" for fromisoformat
463
+ created_norm = created.replace("Z", "+00:00") if created.endswith("Z") else created
464
+ created_dt = datetime.fromisoformat(created_norm)
465
+ if created_dt < tag_dt:
466
+ continue
467
+ except (ValueError, TypeError):
468
+ pass # If parsing fails, include the item
469
+ ledger_items.append({
470
+ "id": item_id,
471
+ "title": item.get("title", ""),
472
+ "priority": item.get("priority", ""),
473
+ })
474
+ except Exception:
475
+ pass # Ledger is optional; failing silently is fine
476
+
477
+ # --- Count stats ---
478
+ test_commits = len(categories["test"])
479
+ total_commits = len(commits_parsed)
480
+
481
+ # Parse files changed from stat summary
482
+ files_changed = 0
483
+ insertions = 0
484
+ deletions = 0
485
+ if stat_summary:
486
+ fc_match = re.search(r"(\d+) files? changed", stat_summary)
487
+ ins_match = re.search(r"(\d+) insertions?\(\+\)", stat_summary)
488
+ del_match = re.search(r"(\d+) deletions?\(-\)", stat_summary)
489
+ if fc_match:
490
+ files_changed = int(fc_match.group(1))
491
+ if ins_match:
492
+ insertions = int(ins_match.group(1))
493
+ if del_match:
494
+ deletions = int(del_match.group(1))
495
+
496
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
497
+ version_label = version or "Unreleased"
498
+
499
+ stats = {
500
+ "total_commits": total_commits,
501
+ "files_changed": files_changed,
502
+ "insertions": insertions,
503
+ "deletions": deletions,
504
+ "tests_added": test_commits,
505
+ "base_tag": base_tag or "(initial)",
506
+ }
507
+
508
+ # --- Category display names (keepachangelog style) ---
509
+ section_map = {
510
+ "feat": "Added",
511
+ "fix": "Fixed",
512
+ "refactor": "Changed",
513
+ "docs": "Documentation",
514
+ "test": "Tests",
515
+ "ci": "CI/CD",
516
+ "chore": "Chores",
517
+ "other": "Other",
518
+ }
519
+
520
+ # --- JSON format ---
521
+ if fmt == "json":
522
+ return {
523
+ "format": "json",
524
+ "version": version_label,
525
+ "date": date_str,
526
+ "stats": stats,
527
+ "categories": {k: v for k, v in categories.items() if v},
528
+ "ledger_items": ledger_items,
529
+ }
530
+
531
+ # --- Markdown formats ---
532
+ lines = []
533
+ if fmt == "github-release":
534
+ # No top-level header for GH release (title comes from the release itself)
535
+ pass
536
+ else:
537
+ lines.append(f"## [{version_label}] - {date_str}")
538
+ lines.append("")
539
+
540
+ for cat_key in ("feat", "fix", "refactor", "docs", "test", "ci", "chore", "other"):
541
+ entries = categories[cat_key]
542
+ if not entries:
543
+ continue
544
+ section_name = section_map[cat_key]
545
+ lines.append(f"### {section_name}")
546
+ for e in entries:
547
+ lines.append(f"- {e['message']} ({e['sha']})")
548
+ lines.append("")
549
+
550
+ if ledger_items:
551
+ lines.append("### Completed Ledger Items")
552
+ for item in ledger_items:
553
+ priority_tag = f"[{item['priority']}] " if item.get("priority") else ""
554
+ lines.append(f"- **{item['id']}**: {priority_tag}{item['title']}")
555
+ lines.append("")
556
+
557
+ # Stats footer
558
+ lines.append("### Stats")
559
+ lines.append(f"- **Commits**: {total_commits}")
560
+ lines.append(f"- **Files changed**: {files_changed}")
561
+ lines.append(f"- **Insertions**: {insertions}(+) / {deletions}(-)")
562
+ if test_commits:
563
+ lines.append(f"- **Test commits**: {test_commits}")
564
+ if base_tag:
565
+ lines.append(f"- **Since**: {base_tag}")
566
+ lines.append("")
567
+
568
+ changelog_text = "\n".join(lines)
569
+
570
+ # --- Write to file if requested ---
571
+ wrote_file = ""
572
+ if output_file:
573
+ out_path = Path(output_file)
574
+ if out_path.name == "CHANGELOG.md" and out_path.exists():
575
+ # Prepend to existing CHANGELOG.md (keep old content)
576
+ existing = out_path.read_text()
577
+ # Insert after the first line if it starts with "# Changelog"
578
+ if existing.startswith("# Changelog"):
579
+ header_end = existing.index("\n") + 1
580
+ new_content = existing[:header_end] + "\n" + changelog_text + existing[header_end:]
581
+ else:
582
+ new_content = "# Changelog\n\n" + changelog_text + "\n" + existing
583
+ out_path.write_text(new_content)
584
+ else:
585
+ out_path.parent.mkdir(parents=True, exist_ok=True)
586
+ out_path.write_text("# Changelog\n\n" + changelog_text)
587
+ wrote_file = str(out_path)
588
+
589
+ return {
590
+ "format": fmt,
591
+ "version": version_label,
592
+ "date": date_str,
593
+ "stats": stats,
594
+ "total_commits": total_commits,
595
+ "ledger_items_count": len(ledger_items),
596
+ "changelog": changelog_text,
597
+ "wrote_file": wrote_file,
598
+ }
599
+
600
+
284
601
  def run_policy(spec_files: List[str], policy_file: Optional[str] = None) -> Dict[str, Any]:
285
602
  """Evaluate specs against governance policy without diffing."""
286
603
  from core.policy_engine import PolicyEngine
@@ -295,6 +612,124 @@ def run_policy(spec_files: List[str], policy_file: Optional[str] = None) -> Dict
295
612
  }
296
613
 
297
614
 
615
+ def simulate_policy(
616
+ old_spec: str,
617
+ new_spec: str,
618
+ policy_file: Optional[str] = None,
619
+ ) -> Dict[str, Any]:
620
+ """Run lint + policy across all presets in dry-run mode.
621
+
622
+ Returns what would pass/fail under strict, default, and relaxed presets,
623
+ plus the custom policy if provided. Nothing is enforced or recorded.
624
+ """
625
+ from core.policy_engine import PolicyEngine, POLICY_PRESETS, evaluate_with_policy
626
+ from core.diff_engine_v2 import OpenAPIDiffEngine
627
+
628
+ old = _load_specs(old_spec)
629
+ new = _load_specs(new_spec)
630
+
631
+ # Run diff once (shared across all preset evaluations)
632
+ diff_engine = OpenAPIDiffEngine()
633
+ changes = diff_engine.compare(old, new)
634
+
635
+ change_dicts = [
636
+ {
637
+ "type": c.type.value,
638
+ "path": c.path,
639
+ "message": c.message,
640
+ "is_breaking": c.is_breaking,
641
+ }
642
+ for c in changes
643
+ ]
644
+
645
+ # Evaluate each preset
646
+ preset_results: Dict[str, Any] = {}
647
+ for preset in POLICY_PRESETS:
648
+ engine = PolicyEngine(preset)
649
+ violations = engine.evaluate(changes)
650
+
651
+ has_errors = any(v.severity == "error" for v in violations)
652
+ has_warnings = any(v.severity == "warning" for v in violations)
653
+
654
+ if has_errors:
655
+ decision = "fail"
656
+ elif has_warnings:
657
+ decision = "warn"
658
+ else:
659
+ decision = "pass"
660
+
661
+ preset_results[preset] = {
662
+ "decision": decision,
663
+ "rules_loaded": len(engine.rules),
664
+ "violations": [
665
+ {
666
+ "rule": v.rule_id,
667
+ "name": v.rule_name,
668
+ "severity": v.severity,
669
+ "message": v.message,
670
+ "path": v.change.path,
671
+ }
672
+ for v in violations
673
+ ],
674
+ "errors": len([v for v in violations if v.severity == "error"]),
675
+ "warnings": len([v for v in violations if v.severity == "warning"]),
676
+ }
677
+
678
+ # Evaluate custom policy if provided (in addition to presets)
679
+ custom_result = None
680
+ if policy_file:
681
+ custom_engine = PolicyEngine(policy_file)
682
+ custom_violations = custom_engine.evaluate(changes)
683
+
684
+ has_errors = any(v.severity == "error" for v in custom_violations)
685
+ has_warnings = any(v.severity == "warning" for v in custom_violations)
686
+
687
+ if has_errors:
688
+ custom_decision = "fail"
689
+ elif has_warnings:
690
+ custom_decision = "warn"
691
+ else:
692
+ custom_decision = "pass"
693
+
694
+ custom_result = {
695
+ "decision": custom_decision,
696
+ "policy_file": policy_file,
697
+ "rules_loaded": len(custom_engine.rules),
698
+ "violations": [
699
+ {
700
+ "rule": v.rule_id,
701
+ "name": v.rule_name,
702
+ "severity": v.severity,
703
+ "message": v.message,
704
+ "path": v.change.path,
705
+ }
706
+ for v in custom_violations
707
+ ],
708
+ "errors": len([v for v in custom_violations if v.severity == "error"]),
709
+ "warnings": len([v for v in custom_violations if v.severity == "warning"]),
710
+ }
711
+
712
+ # Build comparison matrix
713
+ comparison = {}
714
+ for preset in POLICY_PRESETS:
715
+ comparison[preset] = preset_results[preset]["decision"]
716
+ if custom_result:
717
+ comparison["custom"] = custom_result["decision"]
718
+
719
+ return {
720
+ "simulated": True,
721
+ "dry_run": True,
722
+ "summary": {
723
+ "total_changes": len(changes),
724
+ "breaking_changes": len([c for c in changes if c.is_breaking]),
725
+ },
726
+ "all_changes": change_dicts,
727
+ "presets": preset_results,
728
+ "custom_policy": custom_result,
729
+ "comparison": comparison,
730
+ }
731
+
732
+
298
733
  def query_ledger(
299
734
  ledger_path: str,
300
735
  api_name: Optional[str] = None,
@@ -470,3 +905,580 @@ def run_zero_spec(
470
905
  result["error_type"] = "no_framework"
471
906
 
472
907
  return result
908
+
909
+
910
+ def run_diff_report(
911
+ old_spec: str,
912
+ new_spec: str,
913
+ fmt: str = "html",
914
+ output_file: Optional[str] = None,
915
+ policy_file: Optional[str] = None,
916
+ ) -> Dict[str, Any]:
917
+ """Generate a rich comparison report for two API spec versions.
918
+
919
+ Runs the full analysis pipeline (diff, policy, semver, health) and
920
+ produces a self-contained HTML report or structured JSON suitable
921
+ for sharing across teams.
922
+
923
+ Args:
924
+ old_spec: Path to the baseline OpenAPI spec.
925
+ new_spec: Path to the proposed OpenAPI spec.
926
+ fmt: Output format -- "html" or "json".
927
+ output_file: Optional path to write the report file.
928
+ policy_file: Optional custom policy YAML.
929
+
930
+ Returns:
931
+ Dict with report content, metadata, and analysis results.
932
+ """
933
+ from datetime import datetime, timezone
934
+
935
+ from core.diff_engine_v2 import OpenAPIDiffEngine
936
+ from core.policy_engine import PolicyEngine
937
+ from core.semver_classifier import classify_detailed, classify
938
+ from core.spec_health import score_spec
939
+ from core.explainer import explain
940
+
941
+ old = _load_specs(old_spec)
942
+ new = _load_specs(new_spec)
943
+
944
+ # -- Diff --
945
+ engine = OpenAPIDiffEngine()
946
+ changes = engine.compare(old, new)
947
+
948
+ breaking = [c for c in changes if c.is_breaking]
949
+ non_breaking = [c for c in changes if not c.is_breaking]
950
+
951
+ change_dicts = [
952
+ {
953
+ "type": c.type.value,
954
+ "path": c.path,
955
+ "message": c.message,
956
+ "is_breaking": c.is_breaking,
957
+ "details": c.details,
958
+ }
959
+ for c in changes
960
+ ]
961
+
962
+ # -- Semver --
963
+ semver = classify_detailed(changes)
964
+ bump = classify(changes)
965
+
966
+ # -- Policy --
967
+ policy_engine = PolicyEngine(policy_file)
968
+ violations = policy_engine.evaluate(changes)
969
+
970
+ has_errors = any(v.severity == "error" for v in violations)
971
+ has_warnings = any(v.severity == "warning" for v in violations)
972
+ if has_errors:
973
+ gate_decision = "fail"
974
+ elif has_warnings:
975
+ gate_decision = "warn"
976
+ else:
977
+ gate_decision = "pass"
978
+
979
+ violation_dicts = [
980
+ {
981
+ "rule": v.rule_id,
982
+ "name": v.rule_name,
983
+ "severity": v.severity,
984
+ "message": v.message,
985
+ "path": v.change.path,
986
+ }
987
+ for v in violations
988
+ ]
989
+
990
+ # -- Spec health --
991
+ old_health = score_spec(old)
992
+ new_health = score_spec(new)
993
+
994
+ # -- Migration guide (only if breaking changes exist) --
995
+ migration_text = ""
996
+ if breaking:
997
+ try:
998
+ old_ver = old.get("info", {}).get("version")
999
+ new_ver = new.get("info", {}).get("version")
1000
+ migration_text = explain(
1001
+ changes,
1002
+ template="migration",
1003
+ old_version=old_ver,
1004
+ new_version=new_ver,
1005
+ )
1006
+ except Exception:
1007
+ migration_text = ""
1008
+
1009
+ now = datetime.now(timezone.utc)
1010
+ report_data = {
1011
+ "generated_at": now.isoformat(),
1012
+ "old_spec": old_spec,
1013
+ "new_spec": new_spec,
1014
+ "old_version": old.get("info", {}).get("version", "unknown"),
1015
+ "new_version": new.get("info", {}).get("version", "unknown"),
1016
+ "old_title": old.get("info", {}).get("title", ""),
1017
+ "new_title": new.get("info", {}).get("title", ""),
1018
+ "semver": {
1019
+ "bump": semver["bump"],
1020
+ "is_breaking": semver["is_breaking"],
1021
+ "counts": semver["counts"],
1022
+ },
1023
+ "changes": change_dicts,
1024
+ "breaking_count": len(breaking),
1025
+ "non_breaking_count": len(non_breaking),
1026
+ "total_changes": len(changes),
1027
+ "policy": {
1028
+ "decision": gate_decision,
1029
+ "violations": violation_dicts,
1030
+ "errors": len([v for v in violations if v.severity == "error"]),
1031
+ "warnings": len([v for v in violations if v.severity == "warning"]),
1032
+ },
1033
+ "health": {
1034
+ "old": old_health,
1035
+ "new": new_health,
1036
+ },
1037
+ "migration_guide": migration_text,
1038
+ }
1039
+
1040
+ if fmt == "json":
1041
+ wrote_file = ""
1042
+ if output_file:
1043
+ p = Path(output_file)
1044
+ p.parent.mkdir(parents=True, exist_ok=True)
1045
+ p.write_text(json.dumps(report_data, indent=2, default=str), encoding="utf-8")
1046
+ wrote_file = str(p)
1047
+ return {
1048
+ "format": "json",
1049
+ "wrote_file": wrote_file,
1050
+ "report": report_data,
1051
+ }
1052
+
1053
+ # -- HTML generation --
1054
+ html = _render_diff_report_html(report_data)
1055
+
1056
+ wrote_file = ""
1057
+ if output_file:
1058
+ p = Path(output_file)
1059
+ p.parent.mkdir(parents=True, exist_ok=True)
1060
+ p.write_text(html, encoding="utf-8")
1061
+ wrote_file = str(p)
1062
+
1063
+ return {
1064
+ "format": "html",
1065
+ "wrote_file": wrote_file,
1066
+ "html": html,
1067
+ "summary": {
1068
+ "total_changes": report_data["total_changes"],
1069
+ "breaking_count": report_data["breaking_count"],
1070
+ "non_breaking_count": report_data["non_breaking_count"],
1071
+ "semver_bump": report_data["semver"]["bump"],
1072
+ "policy_decision": report_data["policy"]["decision"],
1073
+ "old_health_grade": old_health.get("grade", "?"),
1074
+ "new_health_grade": new_health.get("grade", "?"),
1075
+ },
1076
+ }
1077
+
1078
+
1079
+ def _render_diff_report_html(data: Dict[str, Any]) -> str:
1080
+ """Render the diff report data as a self-contained HTML document."""
1081
+ import html as html_mod
1082
+
1083
+ esc = html_mod.escape
1084
+
1085
+ semver = data["semver"]
1086
+ policy = data["policy"]
1087
+ changes = data["changes"]
1088
+ old_health = data["health"]["old"]
1089
+ new_health = data["health"]["new"]
1090
+
1091
+ # Bump color
1092
+ bump_colors = {
1093
+ "major": "#dc2626",
1094
+ "minor": "#2563eb",
1095
+ "patch": "#16a34a",
1096
+ "none": "#6b7280",
1097
+ }
1098
+ bump_val = semver["bump"].lower() if isinstance(semver["bump"], str) else "none"
1099
+ bump_color = bump_colors.get(bump_val, "#6b7280")
1100
+
1101
+ # Gate color
1102
+ gate_colors = {"fail": "#dc2626", "warn": "#d97706", "pass": "#16a34a"}
1103
+ gate_color = gate_colors.get(policy["decision"], "#6b7280")
1104
+
1105
+ # Build change rows
1106
+ change_rows = []
1107
+ for c in changes:
1108
+ severity_class = "breaking" if c["is_breaking"] else "non-breaking"
1109
+ severity_label = "Breaking" if c["is_breaking"] else "Compatible"
1110
+ severity_dot = "#dc2626" if c["is_breaking"] else "#16a34a"
1111
+ change_rows.append(
1112
+ f'<tr class="{severity_class}">'
1113
+ f'<td><span class="dot" style="background:{severity_dot}"></span> {esc(severity_label)}</td>'
1114
+ f"<td><code>{esc(c['type'])}</code></td>"
1115
+ f"<td><code>{esc(c['path'])}</code></td>"
1116
+ f"<td>{esc(c['message'])}</td>"
1117
+ f"</tr>"
1118
+ )
1119
+ change_rows_html = "\n".join(change_rows) if change_rows else '<tr><td colspan="4" class="empty">No changes detected</td></tr>'
1120
+
1121
+ # Build violation rows
1122
+ violation_rows = []
1123
+ for v in policy["violations"]:
1124
+ sev_color = "#dc2626" if v["severity"] == "error" else "#d97706"
1125
+ violation_rows.append(
1126
+ f"<tr>"
1127
+ f'<td><span class="dot" style="background:{sev_color}"></span> {esc(v["severity"].upper())}</td>'
1128
+ f"<td><code>{esc(v['rule'])}</code></td>"
1129
+ f"<td>{esc(v['message'])}</td>"
1130
+ f"<td><code>{esc(v['path'])}</code></td>"
1131
+ f"</tr>"
1132
+ )
1133
+ violation_rows_html = "\n".join(violation_rows) if violation_rows else '<tr><td colspan="4" class="empty">No policy violations</td></tr>'
1134
+
1135
+ # Health dimensions
1136
+ def _health_dimensions_html(health: Dict[str, Any], label: str) -> str:
1137
+ dims = health.get("dimensions", {})
1138
+ if not dims:
1139
+ return f"<p>{label}: No dimensions available</p>"
1140
+ rows = []
1141
+ for dim_name, dim_data in dims.items():
1142
+ score = dim_data.get("score", 0)
1143
+ bar_color = "#16a34a" if score >= 70 else "#d97706" if score >= 40 else "#dc2626"
1144
+ rows.append(
1145
+ f'<div class="health-row">'
1146
+ f'<span class="health-label">{esc(dim_name.replace("_", " ").title())}</span>'
1147
+ f'<div class="health-bar-bg"><div class="health-bar" style="width:{score}%;background:{bar_color}"></div></div>'
1148
+ f'<span class="health-score">{score}</span>'
1149
+ f"</div>"
1150
+ )
1151
+ return "\n".join(rows)
1152
+
1153
+ old_dims_html = _health_dimensions_html(old_health, "Old")
1154
+ new_dims_html = _health_dimensions_html(new_health, "New")
1155
+
1156
+ # Migration guide
1157
+ migration_html = ""
1158
+ if data.get("migration_guide"):
1159
+ lines = data["migration_guide"].split("\n")
1160
+ migration_parts = []
1161
+ in_pre = False
1162
+ for line in lines:
1163
+ stripped = line.strip()
1164
+ if stripped.startswith("```"):
1165
+ if in_pre:
1166
+ migration_parts.append("</pre>")
1167
+ in_pre = False
1168
+ else:
1169
+ migration_parts.append("<pre>")
1170
+ in_pre = True
1171
+ elif in_pre:
1172
+ migration_parts.append(esc(line))
1173
+ elif stripped.startswith("# "):
1174
+ migration_parts.append(f"<h3>{esc(stripped[2:])}</h3>")
1175
+ elif stripped.startswith("## "):
1176
+ migration_parts.append(f"<h4>{esc(stripped[3:])}</h4>")
1177
+ elif stripped.startswith("### "):
1178
+ migration_parts.append(f"<h5>{esc(stripped[4:])}</h5>")
1179
+ elif stripped.startswith("- "):
1180
+ migration_parts.append(f"<li>{esc(stripped[2:])}</li>")
1181
+ elif stripped:
1182
+ migration_parts.append(f"<p>{esc(stripped)}</p>")
1183
+ if in_pre:
1184
+ migration_parts.append("</pre>")
1185
+ migration_html = f"""
1186
+ <section class="section">
1187
+ <h2>Migration Guide</h2>
1188
+ <div class="migration-content">
1189
+ {"".join(migration_parts)}
1190
+ </div>
1191
+ </section>"""
1192
+
1193
+ html = f"""<!DOCTYPE html>
1194
+ <html lang="en">
1195
+ <head>
1196
+ <meta charset="UTF-8">
1197
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1198
+ <title>API Diff Report -- {esc(data['old_version'])} to {esc(data['new_version'])}</title>
1199
+ <style>
1200
+ :root {{
1201
+ --bg: #f8fafc;
1202
+ --surface: #ffffff;
1203
+ --border: #e2e8f0;
1204
+ --text: #1e293b;
1205
+ --text-muted: #64748b;
1206
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1207
+ --mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, monospace;
1208
+ --radius: 8px;
1209
+ --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
1210
+ }}
1211
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
1212
+ body {{
1213
+ font-family: var(--font);
1214
+ background: var(--bg);
1215
+ color: var(--text);
1216
+ line-height: 1.6;
1217
+ padding: 2rem;
1218
+ max-width: 1100px;
1219
+ margin: 0 auto;
1220
+ }}
1221
+ h1 {{ font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }}
1222
+ h2 {{ font-size: 1.15rem; font-weight: 600; margin-bottom: 1rem; color: var(--text); }}
1223
+ .header {{
1224
+ display: flex;
1225
+ justify-content: space-between;
1226
+ align-items: flex-start;
1227
+ margin-bottom: 1.5rem;
1228
+ flex-wrap: wrap;
1229
+ gap: 0.5rem;
1230
+ }}
1231
+ .header-meta {{ color: var(--text-muted); font-size: 0.85rem; }}
1232
+ .badge {{
1233
+ display: inline-block;
1234
+ padding: 0.2rem 0.6rem;
1235
+ border-radius: 4px;
1236
+ font-size: 0.75rem;
1237
+ font-weight: 600;
1238
+ color: #fff;
1239
+ text-transform: uppercase;
1240
+ letter-spacing: 0.03em;
1241
+ }}
1242
+ .cards {{
1243
+ display: grid;
1244
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1245
+ gap: 1rem;
1246
+ margin-bottom: 1.5rem;
1247
+ }}
1248
+ .card {{
1249
+ background: var(--surface);
1250
+ border: 1px solid var(--border);
1251
+ border-radius: var(--radius);
1252
+ padding: 1.25rem;
1253
+ box-shadow: var(--shadow);
1254
+ }}
1255
+ .card-label {{ font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.4rem; }}
1256
+ .card-value {{ font-size: 1.75rem; font-weight: 700; }}
1257
+ .card-sub {{ font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; }}
1258
+ .section {{
1259
+ background: var(--surface);
1260
+ border: 1px solid var(--border);
1261
+ border-radius: var(--radius);
1262
+ padding: 1.5rem;
1263
+ margin-bottom: 1.5rem;
1264
+ box-shadow: var(--shadow);
1265
+ }}
1266
+ table {{
1267
+ width: 100%;
1268
+ border-collapse: collapse;
1269
+ font-size: 0.875rem;
1270
+ }}
1271
+ th {{
1272
+ text-align: left;
1273
+ padding: 0.6rem 0.75rem;
1274
+ border-bottom: 2px solid var(--border);
1275
+ color: var(--text-muted);
1276
+ font-weight: 600;
1277
+ font-size: 0.75rem;
1278
+ text-transform: uppercase;
1279
+ letter-spacing: 0.04em;
1280
+ }}
1281
+ td {{
1282
+ padding: 0.6rem 0.75rem;
1283
+ border-bottom: 1px solid var(--border);
1284
+ vertical-align: top;
1285
+ }}
1286
+ tr:last-child td {{ border-bottom: none; }}
1287
+ tr.breaking {{ background: #fef2f2; }}
1288
+ code {{
1289
+ font-family: var(--mono);
1290
+ font-size: 0.8rem;
1291
+ background: #f1f5f9;
1292
+ padding: 0.15rem 0.4rem;
1293
+ border-radius: 3px;
1294
+ }}
1295
+ .dot {{
1296
+ display: inline-block;
1297
+ width: 8px;
1298
+ height: 8px;
1299
+ border-radius: 50%;
1300
+ margin-right: 0.4rem;
1301
+ vertical-align: middle;
1302
+ }}
1303
+ .empty {{
1304
+ text-align: center;
1305
+ color: var(--text-muted);
1306
+ padding: 1.5rem;
1307
+ font-style: italic;
1308
+ }}
1309
+ .health-grid {{
1310
+ display: grid;
1311
+ grid-template-columns: 1fr 1fr;
1312
+ gap: 1.5rem;
1313
+ }}
1314
+ .health-col h3 {{
1315
+ font-size: 0.95rem;
1316
+ font-weight: 600;
1317
+ margin-bottom: 0.75rem;
1318
+ }}
1319
+ .health-row {{
1320
+ display: flex;
1321
+ align-items: center;
1322
+ gap: 0.5rem;
1323
+ margin-bottom: 0.5rem;
1324
+ }}
1325
+ .health-label {{
1326
+ width: 120px;
1327
+ font-size: 0.8rem;
1328
+ color: var(--text-muted);
1329
+ flex-shrink: 0;
1330
+ }}
1331
+ .health-bar-bg {{
1332
+ flex: 1;
1333
+ height: 8px;
1334
+ background: #e2e8f0;
1335
+ border-radius: 4px;
1336
+ overflow: hidden;
1337
+ }}
1338
+ .health-bar {{
1339
+ height: 100%;
1340
+ border-radius: 4px;
1341
+ transition: width 0.3s ease;
1342
+ }}
1343
+ .health-score {{
1344
+ width: 30px;
1345
+ text-align: right;
1346
+ font-size: 0.8rem;
1347
+ font-weight: 600;
1348
+ flex-shrink: 0;
1349
+ }}
1350
+ .migration-content {{
1351
+ font-size: 0.9rem;
1352
+ line-height: 1.7;
1353
+ }}
1354
+ .migration-content h3 {{ font-size: 1.05rem; margin: 1rem 0 0.5rem; }}
1355
+ .migration-content h4 {{ font-size: 0.95rem; margin: 0.75rem 0 0.4rem; }}
1356
+ .migration-content h5 {{ font-size: 0.9rem; margin: 0.5rem 0 0.3rem; }}
1357
+ .migration-content li {{ margin-left: 1.5rem; margin-bottom: 0.25rem; }}
1358
+ .migration-content pre {{
1359
+ background: #1e293b;
1360
+ color: #e2e8f0;
1361
+ padding: 1rem;
1362
+ border-radius: var(--radius);
1363
+ overflow-x: auto;
1364
+ font-family: var(--mono);
1365
+ font-size: 0.8rem;
1366
+ margin: 0.5rem 0;
1367
+ }}
1368
+ .footer {{
1369
+ text-align: center;
1370
+ color: var(--text-muted);
1371
+ font-size: 0.75rem;
1372
+ margin-top: 2rem;
1373
+ padding-top: 1rem;
1374
+ border-top: 1px solid var(--border);
1375
+ }}
1376
+ @media (max-width: 600px) {{
1377
+ body {{ padding: 1rem; }}
1378
+ .cards {{ grid-template-columns: 1fr 1fr; }}
1379
+ .health-grid {{ grid-template-columns: 1fr; }}
1380
+ }}
1381
+ @media print {{
1382
+ body {{ padding: 0; }}
1383
+ .section {{ break-inside: avoid; }}
1384
+ }}
1385
+ </style>
1386
+ </head>
1387
+ <body>
1388
+
1389
+ <div class="header">
1390
+ <div>
1391
+ <h1>{esc(data.get('new_title') or data.get('old_title') or 'API')} -- Diff Report</h1>
1392
+ <div class="header-meta">
1393
+ {esc(data['old_version'])} &rarr; {esc(data['new_version'])}
1394
+ &middot; Generated {esc(data['generated_at'][:19])} UTC
1395
+ </div>
1396
+ </div>
1397
+ <div>
1398
+ <span class="badge" style="background:{bump_color}">{esc(bump_val.upper())}</span>
1399
+ <span class="badge" style="background:{gate_color}">Gate: {esc(policy['decision'].upper())}</span>
1400
+ </div>
1401
+ </div>
1402
+
1403
+ <div class="cards">
1404
+ <div class="card">
1405
+ <div class="card-label">Total Changes</div>
1406
+ <div class="card-value">{data['total_changes']}</div>
1407
+ </div>
1408
+ <div class="card">
1409
+ <div class="card-label">Breaking</div>
1410
+ <div class="card-value" style="color:#dc2626">{data['breaking_count']}</div>
1411
+ </div>
1412
+ <div class="card">
1413
+ <div class="card-label">Non-Breaking</div>
1414
+ <div class="card-value" style="color:#16a34a">{data['non_breaking_count']}</div>
1415
+ </div>
1416
+ <div class="card">
1417
+ <div class="card-label">Semver Bump</div>
1418
+ <div class="card-value" style="color:{bump_color}">{esc(bump_val.upper())}</div>
1419
+ <div class="card-sub">{semver['counts'].get('breaking', 0)} breaking, {semver['counts'].get('additive', 0)} additive, {semver['counts'].get('patch', 0)} patch</div>
1420
+ </div>
1421
+ <div class="card">
1422
+ <div class="card-label">Policy Gate</div>
1423
+ <div class="card-value" style="color:{gate_color}">{esc(policy['decision'].upper())}</div>
1424
+ <div class="card-sub">{policy['errors']} errors, {policy['warnings']} warnings</div>
1425
+ </div>
1426
+ </div>
1427
+
1428
+ <section class="section">
1429
+ <h2>Changes</h2>
1430
+ <table>
1431
+ <thead>
1432
+ <tr>
1433
+ <th style="width:100px">Severity</th>
1434
+ <th style="width:160px">Type</th>
1435
+ <th style="width:220px">Path</th>
1436
+ <th>Description</th>
1437
+ </tr>
1438
+ </thead>
1439
+ <tbody>
1440
+ {change_rows_html}
1441
+ </tbody>
1442
+ </table>
1443
+ </section>
1444
+
1445
+ <section class="section">
1446
+ <h2>Policy Violations</h2>
1447
+ <table>
1448
+ <thead>
1449
+ <tr>
1450
+ <th style="width:100px">Severity</th>
1451
+ <th style="width:160px">Rule</th>
1452
+ <th>Message</th>
1453
+ <th style="width:200px">Path</th>
1454
+ </tr>
1455
+ </thead>
1456
+ <tbody>
1457
+ {violation_rows_html}
1458
+ </tbody>
1459
+ </table>
1460
+ </section>
1461
+ {migration_html}
1462
+ <section class="section">
1463
+ <h2>Spec Health</h2>
1464
+ <div class="health-grid">
1465
+ <div class="health-col">
1466
+ <h3>Baseline -- {esc(data['old_version'])} (Grade: {esc(str(old_health.get('grade', '?')))} Score: {old_health.get('overall_score', '?')})</h3>
1467
+ {old_dims_html}
1468
+ </div>
1469
+ <div class="health-col">
1470
+ <h3>Proposed -- {esc(data['new_version'])} (Grade: {esc(str(new_health.get('grade', '?')))} Score: {new_health.get('overall_score', '?')})</h3>
1471
+ {new_dims_html}
1472
+ </div>
1473
+ </div>
1474
+ </section>
1475
+
1476
+ <div class="footer">
1477
+ Generated by Delimit -- API governance for teams that ship.
1478
+ &middot; <a href="https://delimit.ai" style="color:var(--text-muted)">delimit.ai</a>
1479
+ </div>
1480
+
1481
+ </body>
1482
+ </html>"""
1483
+
1484
+ return html