Files
Momento/.agent/skills/suno-agent-band-manager/scripts/validate-sidecar.py
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

762 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate the Mac sidecar index against songbook + band-profile ground truth.
Reads every songbook entry and band profile, derives the ground-truth catalog
state, and compares it against the claims in the sidecar index.md. Reports
drift as structured findings. Exits 0 on clean, 1 on drift (CI-friendly).
Cross-platform: pure Python stdlib + PyYAML (already a module dependency).
Usage:
python3 scripts/validate-sidecar.py [project_root]
python3 scripts/validate-sidecar.py --format json
python3 scripts/validate-sidecar.py --warn-only # exit 0 even with findings
Checks performed:
1. Songbook internal consistency — frontmatter status/date vs. body status marker
2. Audio file existence for published songs
3. Sidecar Recently Published list matches songbook ground truth
4. Sidecar Catalog Status counts match actual songbook counts
5. Playlist YAML track count matches songbook count for that band
6. Markdown cross-references in docs/ resolve to existing files
Called by:
- pack-portable.{sh,ps1} before packing (gates sync)
- save-memory workflow after index.md writes (validates derivation)
- Standalone by user any time for a consistency check
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
print(
json.dumps(
{
"status": "error",
"message": "PyYAML required. Install with: pip install pyyaml",
}
)
)
sys.exit(2)
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class Song:
path: Path
band: str
title: str
frontmatter_status: str | None
frontmatter_date: str | None
body_status: str | None # "LOCKED", "PUBLISHED", "WIP", or None
body_date: str | None
body_description: str | None
audio_references: list[str] = field(default_factory=list)
@property
def is_published(self) -> bool:
"""Single source of truth: requires frontmatter + body to agree on published."""
frontmatter_published = self.frontmatter_status == "published"
body_published = self.body_status in ("LOCKED", "PUBLISHED")
return frontmatter_published and body_published
@dataclass
class Finding:
category: str # "songbook_drift" | "audio_missing" | "index_drift" | "playlist_drift" | "cross_reference_missing"
severity: str # "error" | "warning"
path: str
message: str
def to_dict(self) -> dict[str, str]:
return {
"category": self.category,
"severity": self.severity,
"path": self.path,
"message": self.message,
}
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
STATUS_MARKER_RE = re.compile(
r"\*\*Status:\s*(LOCKED|PUBLISHED|WIP)"
r"(?:\s*[—-]\s*(?:v\d+\s+)?Published\s+(\d{4}-\d{2}-\d{2}))?"
r"(?:\s*\((\d{4}-\d{2}-\d{2})\))?"
r"\.?\s*(.*?)\*\*",
re.DOTALL,
)
AUDIO_REF_RE = re.compile(r"`(docs/audio/[^`]+\.(?:mp3|wav|flac|m4a))`")
def parse_song(path: Path, project_root: Path) -> tuple[Song | None, str | None]:
"""Parse a songbook markdown file.
Returns a (song, error) pair:
- (Song, None) when parsing succeeds
- (None, None) when the file has no frontmatter (likely not a song)
- (None, error_msg) when YAML frontmatter fails to parse
"""
text = path.read_text(encoding="utf-8")
fm_match = FRONTMATTER_RE.match(text)
if not fm_match:
return None, None
try:
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
except yaml.YAMLError as exc:
return None, f"YAML frontmatter parse error: {exc}"
body = text[fm_match.end() :]
# Body status marker: walk matches and pick the last one (body markers
# appear after Generation Log notes that may reference earlier WIP states).
body_status = body_date = body_description = None
for m in STATUS_MARKER_RE.finditer(body):
body_status = m.group(1)
body_date = m.group(2) or m.group(3)
body_description = (m.group(4) or "").strip()
audio_refs = AUDIO_REF_RE.findall(body)
band = frontmatter.get("band_profile", "")
title = frontmatter.get("title", path.stem)
return (
Song(
path=path.relative_to(project_root),
band=band,
title=str(title),
frontmatter_status=frontmatter.get("status"),
frontmatter_date=str(frontmatter.get("date")) if frontmatter.get("date") else None,
body_status=body_status,
body_date=body_date,
body_description=body_description,
audio_references=audio_refs,
),
None,
)
def load_all_songs(project_root: Path) -> tuple[list[Song], list[Finding]]:
"""Load every songbook entry plus any parse-failure findings.
Songs whose YAML frontmatter fails to parse used to be silently dropped,
which hid songs from derived sections without surfacing any error (issue #29).
Each parse failure now becomes a songbook_drift error so sync can't pass
while a song is invisible to the index generator.
"""
songbook_root = project_root / "docs" / "songbook"
if not songbook_root.is_dir():
return [], []
songs: list[Song] = []
parse_findings: list[Finding] = []
for path in sorted(songbook_root.rglob("*.md")):
song, error = parse_song(path, project_root)
if song is not None:
songs.append(song)
elif error is not None:
parse_findings.append(
Finding(
category="songbook_drift",
severity="error",
path=str(path.relative_to(project_root)),
message=(
f"{error} — song will be skipped by derived-section "
"generators. Fix by quoting values containing "
"special YAML characters (e.g. inner brackets)."
),
)
)
return songs, parse_findings
# ---------------------------------------------------------------------------
# Check implementations
# ---------------------------------------------------------------------------
def check_songbook_consistency(song: Song) -> list[Finding]:
"""Frontmatter and body must agree on status + date."""
findings: list[Finding] = []
path = str(song.path)
frontmatter_published = song.frontmatter_status == "published"
body_published = song.body_status in ("LOCKED", "PUBLISHED")
if song.body_status is None and frontmatter_published:
# Missing marker is data incompleteness, not contradiction.
# Warning keeps pre-existing songbook gaps from blocking sync.
findings.append(
Finding(
category="songbook_drift",
severity="warning",
path=path,
message="frontmatter status=published but no body Status marker found",
)
)
elif frontmatter_published != body_published and song.body_status is not None:
findings.append(
Finding(
category="songbook_drift",
severity="error",
path=path,
message=(
f"frontmatter status={song.frontmatter_status!r} disagrees with "
f"body Status: {song.body_status}"
),
)
)
if (
frontmatter_published
and body_published
and song.frontmatter_date
and song.body_date
and song.frontmatter_date != song.body_date
):
findings.append(
Finding(
category="songbook_drift",
severity="error",
path=path,
message=(
f"frontmatter date={song.frontmatter_date} disagrees with "
f"body Published {song.body_date}"
),
)
)
return findings
def check_audio_exists(song: Song, project_root: Path) -> list[Finding]:
"""Every audio reference in a published song must exist on disk."""
if not song.is_published:
return []
findings: list[Finding] = []
for rel in song.audio_references:
audio_path = project_root / rel
if not audio_path.exists():
findings.append(
Finding(
category="audio_missing",
severity="warning",
path=str(song.path),
message=f"referenced audio file not found: {rel}",
)
)
return findings
def check_index_recently_published(
index_text: str, songs: list[Song]
) -> list[Finding]:
"""Every song listed in Recently Published must match songbook ground truth."""
findings: list[Finding] = []
index_path = "_bmad/_memory/band-manager-sidecar/index.md"
# Extract the Recently Published block (from that heading until the next ## heading)
recent_match = re.search(
r"^##\s+Recently Published\s*\n(.*?)(?=\n##\s)",
index_text,
re.MULTILINE | re.DOTALL,
)
if not recent_match:
return []
block = recent_match.group(1)
# Each entry looks like: - **Title** (YYYY-MM-DD, STATUS) — ...
entry_re = re.compile(
r"-\s+\*\*(?P<title>[^*]+?)\*\*\s*"
r"\((?P<date>\d{4}-\d{2}-\d{2}),\s*(?P<status>[A-Za-z]+)",
)
for match in entry_re.finditer(block):
title = match.group("title").strip()
claimed_date = match.group("date")
claimed_status = match.group("status").upper()
# Match title allowing for minor suffix (e.g., "Observation v2" matches "Observation").
# Multiple songs can share a title across bands (same poem, different interpretations),
# so disambiguate by date: prefer the song whose body or frontmatter date matches
# what the index claims.
candidates = [
s for s in songs if s.title == title or title.startswith(s.title)
]
matched = None
for c in candidates:
if c.body_date == claimed_date or c.frontmatter_date == claimed_date:
matched = c
break
if matched is None and candidates:
matched = candidates[0]
if matched is None:
findings.append(
Finding(
category="index_drift",
severity="error",
path=index_path,
message=(
f"Recently Published lists {title!r} but no songbook entry "
f"has that title"
),
)
)
continue
# Status must agree — index claims vs. songbook ground truth
song_published = matched.is_published
index_claims_published = claimed_status in ("PUBLISHED", "LOCKED")
if song_published != index_claims_published:
findings.append(
Finding(
category="index_drift",
severity="error",
path=index_path,
message=(
f"{title!r} listed as {claimed_status} but songbook shows "
f"frontmatter={matched.frontmatter_status!r} "
f"body_marker={matched.body_status!r}"
),
)
)
# Date must agree with body_date (authoritative) if published
if song_published and matched.body_date and claimed_date != matched.body_date:
findings.append(
Finding(
category="index_drift",
severity="error",
path=index_path,
message=(
f"{title!r} listed with date {claimed_date} but "
f"songbook Status marker says Published {matched.body_date}"
),
)
)
return findings
def check_index_catalog_counts(
index_text: str, songs: list[Song], project_root: Path
) -> list[Finding]:
"""Catalog Status counts must match actual songbook + playlist ground truth."""
findings: list[Finding] = []
index_path = "_bmad/_memory/band-manager-sidecar/index.md"
# Extract the Catalog Status block
catalog_match = re.search(
r"^##\s+Catalog Status\s*\n(.*?)(?=\n##\s)",
index_text,
re.MULTILINE | re.DOTALL,
)
if not catalog_match:
return findings
block = catalog_match.group(1)
# Check claims of the form: "**Band Name:** **N published tracks**" or "**Band:** N-track playlist"
per_band_claims = re.finditer(
r"\*\*(?P<band>[^:*]+):\*\*\s*"
r"(?:\*\*)?(?P<count>\d+)[-\s](?:published\s+tracks|track\s+playlist)",
block,
re.IGNORECASE,
)
# Build ground-truth counts per band (from songbook status + playlist files)
published_per_band: dict[str, int] = {}
all_per_band: dict[str, int] = {}
for song in songs:
all_per_band[song.band] = all_per_band.get(song.band, 0) + 1
if song.is_published:
published_per_band[song.band] = published_per_band.get(song.band, 0) + 1
# Band name in index → band slug mapping. Derived dynamically from
# band profile YAMLs at runtime so this works for any project's bands,
# not just one specific project's hardcoded list.
band_slugs: dict[str, str] = {}
profiles_dir = project_root / "docs" / "band-profiles"
if profiles_dir.is_dir():
for profile_path in sorted(profiles_dir.glob("*.yaml")):
try:
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
except yaml.YAMLError:
continue
if isinstance(profile, dict):
display_name = (profile.get("name") or "").strip()
if display_name:
band_slugs[display_name] = profile_path.stem
for match in per_band_claims:
band_display = match.group("band").strip()
claimed = int(match.group("count"))
slug = band_slugs.get(band_display)
if slug is None:
continue
# Figure out whether this is a "published tracks" claim or "playlist" claim
is_playlist_claim = "playlist" in match.group(0).lower()
if is_playlist_claim:
# Cross-check against the playlist YAML if it exists
playlist_path = project_root / "docs" / f"{slug}-playlist.yaml"
if playlist_path.exists():
try:
playlist = yaml.safe_load(playlist_path.read_text(encoding="utf-8"))
actual_tracks = len(playlist.get("tracks", []) or [])
if actual_tracks != claimed:
findings.append(
Finding(
category="index_drift",
severity="warning",
path=index_path,
message=(
f"{band_display!r} claimed {claimed}-track playlist "
f"but {playlist_path.name} has {actual_tracks} tracks"
),
)
)
except yaml.YAMLError:
pass
else:
actual_published = published_per_band.get(slug, 0)
if actual_published != claimed:
findings.append(
Finding(
category="index_drift",
severity="error",
path=index_path,
message=(
f"{band_display!r} claimed {claimed} published tracks "
f"but songbook has {actual_published} with status=published + body marker"
),
)
)
return findings
def check_playlist_songbook_parity(
songs: list[Song], project_root: Path
) -> list[Finding]:
"""Playlist YAMLs should reference songs that exist in the songbook."""
findings: list[Finding] = []
playlist_dir = project_root / "docs"
if not playlist_dir.is_dir():
return findings
for playlist_path in sorted(playlist_dir.glob("*-playlist.yaml")):
slug = playlist_path.name.replace("-playlist.yaml", "")
try:
playlist = yaml.safe_load(playlist_path.read_text(encoding="utf-8"))
except yaml.YAMLError:
continue
if not isinstance(playlist, dict):
continue
track_count = len(playlist.get("tracks", []) or [])
songbook_count = sum(1 for s in songs if s.band == slug)
if track_count != songbook_count:
findings.append(
Finding(
category="playlist_drift",
severity="warning",
path=str(playlist_path.relative_to(project_root)),
message=(
f"{track_count} tracks in playlist YAML but "
f"{songbook_count} songbook entries for band {slug!r}"
),
)
)
return findings
# ---------------------------------------------------------------------------
# Cross-reference check
# ---------------------------------------------------------------------------
# Inline-code reference: `path/to/file.md` or `path/to/file.md#anchor`
# We require at least one slash or dot-segment so bare `README.md` in running
# prose still matches but single-word code spans like `status` don't.
INLINE_CODE_REF_RE = re.compile(r"`([^`\s]+\.md(?:#[^`]*)?)`")
# Markdown link reference: [text](path.md) or [text](path.md#anchor)
# Negative lookbehind on ! avoids matching image syntax ![alt](...).
MARKDOWN_LINK_REF_RE = re.compile(
r"(?<!!)\[[^\]]*\]\(([^)\s]+?\.md(?:#[^)\s]*)?)\)"
)
def _is_external_or_anchor(ref: str) -> bool:
"""Skip external URLs, mail links, and bare anchor references."""
lowered = ref.strip().lower()
if lowered.startswith(("http://", "https://", "mailto:", "ftp://", "//")):
return True
if lowered.startswith("#"):
return True
return False
def _strip_code_fences(text: str) -> str:
"""Remove fenced code blocks so references inside them are not checked.
References inside inline backticks (single backtick spans) are still checked,
since those are the canonical form for pointing at a file in prose. But
multi-line ``` fences often contain examples, templates, or diffs that
shouldn't be validated against the real filesystem.
"""
return re.sub(r"```.*?```", "", text, flags=re.DOTALL)
def check_markdown_cross_references(project_root: Path) -> list[Finding]:
"""Scan every markdown file under docs/ for broken cross-references.
Catches forward-intent references (`docs/X.md` mentioned declaratively but
never actually created) and stale references that slipped past the delete
reconciliation protocol.
Scope: `docs/` only — module source references (`src/skills/...`) are out
of scope because they follow different drift semantics (tracked in git, not
synced machine-to-machine).
Matches:
- Inline code: `path/to/file.md` (single backtick spans)
- Markdown links: [text](path/to/file.md) including relative `../` paths
Skips:
- External URLs (http/https/mailto/ftp)
- Anchor-only refs (#section)
- Self-references
- Anything inside fenced code blocks (``` ... ```)
"""
findings: list[Finding] = []
docs_root = project_root / "docs"
if not docs_root.is_dir():
return findings
for md_path in sorted(docs_root.rglob("*.md")):
try:
text = md_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
scannable = _strip_code_fences(text)
rel_referrer = str(md_path.relative_to(project_root))
seen: set[str] = set()
for pattern in (INLINE_CODE_REF_RE, MARKDOWN_LINK_REF_RE):
for match in pattern.finditer(scannable):
raw_ref = match.group(1).strip()
if _is_external_or_anchor(raw_ref):
continue
# Strip URL-style anchor suffix for file existence check
ref_path_part = raw_ref.split("#", 1)[0]
if not ref_path_part:
continue
# Deduplicate per-file so one broken reference reported once
if ref_path_part in seen:
continue
seen.add(ref_path_part)
# Absolute-ish refs (starting with /) are machine paths — skip.
if ref_path_part.startswith("/"):
continue
# Glob/wildcard patterns (e.g. `per-candidate/*.md`) describe
# a directory of files, not a single target — skip them.
if any(c in ref_path_part for c in "*?["):
continue
# References can be either parent-relative (`../foo.md`) or
# project-root-relative (`docs/foo.md` written from inside
# `docs/` — the user convention in this codebase). Try both
# anchors; if either target exists, the reference is valid.
project_abs = project_root.resolve()
parent_resolved = (md_path.parent / ref_path_part).resolve()
root_resolved = (project_root / ref_path_part).resolve()
referrer_abs = md_path.resolve()
# Self-reference check against either resolution
if parent_resolved == referrer_abs or root_resolved == referrer_abs:
continue
# Does either candidate exist under the project root?
candidates = []
for cand in (parent_resolved, root_resolved):
try:
cand.relative_to(project_abs)
except ValueError:
continue
candidates.append(cand)
if not candidates:
# Both candidates escape the project root — out of scope
continue
if any(c.exists() for c in candidates):
continue
# Neither exists — report using the more informative target
# (prefer project-root-relative when the reference looked like
# one, else the parent-relative form).
display_target = candidates[-1] if len(candidates) > 1 else candidates[0]
try:
target_display = str(display_target.relative_to(project_abs))
except ValueError:
target_display = str(display_target)
findings.append(
Finding(
category="cross_reference_missing",
severity="warning",
path=rel_referrer,
message=(
f"reference to {raw_ref!r} → target not found: "
f"{target_display}"
),
)
)
return findings
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def run_checks(project_root: Path) -> tuple[list[Finding], dict[str, int]]:
songs, parse_findings = load_all_songs(project_root)
findings: list[Finding] = list(parse_findings)
for song in songs:
findings.extend(check_songbook_consistency(song))
findings.extend(check_audio_exists(song, project_root))
index_path = project_root / "_bmad" / "_memory" / "band-manager-sidecar" / "index.md"
if index_path.exists():
index_text = index_path.read_text(encoding="utf-8")
findings.extend(check_index_recently_published(index_text, songs))
findings.extend(check_index_catalog_counts(index_text, songs, project_root))
findings.extend(check_playlist_songbook_parity(songs, project_root))
findings.extend(check_markdown_cross_references(project_root))
stats = {
"songs_scanned": len(songs),
"songs_published": sum(1 for s in songs if s.is_published),
"findings_total": len(findings),
"findings_error": sum(1 for f in findings if f.severity == "error"),
"findings_warning": sum(1 for f in findings if f.severity == "warning"),
}
return findings, stats
def format_text(findings: list[Finding], stats: dict[str, int]) -> str:
lines = [
"Sidecar Validation Report",
"=" * 25,
f"Songs scanned: {stats['songs_scanned']} "
f"({stats['songs_published']} published)",
f"Findings: {stats['findings_total']} "
f"({stats['findings_error']} errors, {stats['findings_warning']} warnings)",
"",
]
if not findings:
lines.append("PASS — no drift detected.")
return "\n".join(lines)
# Group by category for readable output
by_category: dict[str, list[Finding]] = {}
for f in findings:
by_category.setdefault(f.category, []).append(f)
for category, items in sorted(by_category.items()):
lines.append(f"[{category.upper()}]")
for f in items:
lines.append(f" ({f.severity}) {f.path}")
lines.append(f" {f.message}")
lines.append("")
if stats["findings_error"] > 0:
lines.append(
f"FAIL — {stats['findings_error']} error(s) block sidecar sync."
)
else:
lines.append(
f"PASS (with {stats['findings_warning']} warning(s)) — no blocking errors."
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate Mac sidecar index against songbook ground truth."
)
parser.add_argument(
"project_root",
nargs="?",
default=".",
help="Project root directory (default: current directory)",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text)",
)
parser.add_argument(
"--warn-only",
action="store_true",
help="Exit 0 even when errors are found (for advisory runs)",
)
args = parser.parse_args()
project_root = Path(args.project_root).resolve()
if not project_root.is_dir():
print(f"ERROR: project root not found: {project_root}", file=sys.stderr)
return 2
findings, stats = run_checks(project_root)
if args.format == "json":
payload: dict[str, Any] = {
"status": "pass" if stats["findings_error"] == 0 else "fail",
"stats": stats,
"findings": [f.to_dict() for f in findings],
}
print(json.dumps(payload, indent=2))
else:
print(format_text(findings, stats))
if args.warn_only:
return 0
return 0 if stats["findings_error"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())