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.
- package/CHANGELOG.md +578 -0
- package/bin/delimit-cli.js +91 -0
- package/gateway/ai/backends/gateway_core.py +1012 -0
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/screen_record.py +1 -1
- package/gateway/ai/server.py +594 -138
- package/gateway/ai/swarm.py +1 -1
- package/gateway/core/diff_engine_v2.py +5 -0
- package/gateway/core/spec_health.py +624 -0
- package/package.json +1 -1
|
@@ -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'])} → {esc(data['new_version'])}
|
|
1394
|
+
· 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
|
+
· <a href="https://delimit.ai" style="color:var(--text-muted)">delimit.ai</a>
|
|
1479
|
+
</div>
|
|
1480
|
+
|
|
1481
|
+
</body>
|
|
1482
|
+
</html>"""
|
|
1483
|
+
|
|
1484
|
+
return html
|