from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, Set, List
SNAP_DIR_REL = Path("examples/snapshots")
MD_DIR_REL = Path(".gh-pages")
REFERENCE_REGEX = re.compile(
r"examples/snapshots/([A-Za-z0-9_.@-]+\.snap\.(?:svg|wav))(?!\.new)",
re.IGNORECASE,
)
class SnapshotInfo:
def __init__(self, snap_path: Path):
self.snap_path = snap_path
self.name = snap_path.name self.is_audio = "@audio" in self.name
self.stem = self.name self.chart_referenced = False
self.audio_referenced = False
def expected_chart(self) -> str:
return f"{self.stem}.svg"
def expected_audio(self) -> str:
return f"{self.stem}.wav"
def to_dict(self) -> Dict[str, object]:
return {
"snap": self.name,
"is_audio": self.is_audio,
"chart_referenced": self.chart_referenced,
"audio_referenced": self.audio_referenced,
}
def gather_snapshots(snapshots_dir: Path) -> Dict[str, SnapshotInfo]:
mapping: Dict[str, SnapshotInfo] = {}
if not snapshots_dir.is_dir():
raise SystemExit(f"Snapshot directory not found: {snapshots_dir}")
for snap in sorted(snapshots_dir.glob("*.snap")):
info = SnapshotInfo(snap)
mapping[info.name] = info
return mapping
def parse_markdown(md_dir: Path) -> List[Path]:
if not md_dir.is_dir():
raise SystemExit(f"Markdown directory not found: {md_dir}")
return sorted(md_dir.glob("*.md"))
def analyze(
snapshots: Dict[str, SnapshotInfo],
md_files: List[Path],
require_audio_svg: bool,
) -> Dict[str, object]:
referenced_resources: Set[str] = set() orphan_references: Set[str] = set()
for md in md_files:
text = md.read_text(encoding="utf-8", errors="replace")
for match in REFERENCE_REGEX.finditer(text):
resource = match.group(1) if ".new." in resource:
continue
referenced_resources.add(resource)
underlying_snap = resource.rsplit(".", 1)[0] if underlying_snap not in snapshots:
orphan_references.add(resource)
continue
snap_info = snapshots[underlying_snap]
if resource.endswith(".svg"):
snap_info.chart_referenced = True
elif resource.endswith(".wav"):
snap_info.audio_referenced = True
missing_chart_refs = [
s.name for s in snapshots.values() if not s.is_audio and not s.chart_referenced
]
missing_audio_refs = [
s.name for s in snapshots.values() if s.is_audio and not s.audio_referenced
]
missing_audio_svg_refs: List[str] = []
if require_audio_svg:
missing_audio_svg_refs = [
s.name for s in snapshots.values() if s.is_audio and not s.chart_referenced
]
unreferenced_snapshots = [
s.name
for s in snapshots.values()
if (not s.chart_referenced and (not s.is_audio or not s.audio_referenced))
and (not s.is_audio or not s.audio_referenced)
]
return {
"missing_chart_refs": sorted(missing_chart_refs),
"missing_audio_refs": sorted(missing_audio_refs),
"missing_audio_svg_refs": sorted(missing_audio_svg_refs),
"orphan_references": sorted(orphan_references),
"unreferenced_snapshots": sorted(unreferenced_snapshots),
"total_snapshots": len(snapshots),
"snapshots": [s.to_dict() for s in snapshots.values()],
}
def print_human(report: Dict[str, object], strict: bool, verbose: bool) -> int:
def section(title: str, items: List[str]):
print(f"\n{title}:")
if items:
for it in items:
print(f" - {it}")
else:
print(" (none)")
print("Gallery Verification Report")
print("===========================")
print(f"Total snapshots: {report['total_snapshots']}")
section("Missing chart references (non-audio)", report["missing_chart_refs"])
section("Missing audio references (@audio)", report["missing_audio_refs"])
if report["missing_audio_svg_refs"]:
section(
"Missing audio chart references (@audio, enforced)",
report["missing_audio_svg_refs"],
)
section("Orphan markdown references", report["orphan_references"])
if verbose:
section("Unreferenced snapshots (informational)", report["unreferenced_snapshots"])
error_conditions = (
report["missing_chart_refs"]
or report["missing_audio_refs"]
or report["orphan_references"]
or (strict and report["unreferenced_snapshots"])
or report["missing_audio_svg_refs"]
)
if error_conditions:
print("\nResult: FAIL")
return 1
print("\nResult: OK")
return 0
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(
description="Verify that markdown pages reference existing snapshot artifacts."
)
parser.add_argument("--json", action="store_true", help="Output JSON report.")
parser.add_argument(
"--strict",
action="store_true",
help="Treat unreferenced snapshots as errors (affects exit code).",
)
parser.add_argument(
"--root",
type=Path,
default=Path(__file__).resolve().parent.parent,
help="Project root (default: parent of script directory).",
)
parser.add_argument(
"--require-audio-svg",
action="store_true",
help="Require .snap.svg reference for audio snapshots in addition to .wav.",
)
parser.add_argument(
"--verbose", action="store_true", help="Show additional informational sections."
)
args = parser.parse_args(argv)
snapshots_dir = args.root / SNAP_DIR_REL
md_dir = args.root / MD_DIR_REL
snapshots = gather_snapshots(snapshots_dir)
md_files = parse_markdown(md_dir)
report = analyze(
snapshots=snapshots,
md_files=md_files,
require_audio_svg=args.require_audio_svg,
)
if args.json:
print(json.dumps(report, indent=2))
fail = (
report["missing_chart_refs"]
or report["missing_audio_refs"]
or report["orphan_references"]
or (args.strict and report["unreferenced_snapshots"])
or report["missing_audio_svg_refs"]
)
return 1 if fail else 0
return print_human(report, strict=args.strict, verbose=args.verbose)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))