feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- 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
This commit is contained in:
761
.agent/skills/suno-agent-band-manager/scripts/validate-sidecar.py
Executable file
761
.agent/skills/suno-agent-band-manager/scripts/validate-sidecar.py
Executable file
@@ -0,0 +1,761 @@
|
||||
#!/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 .
|
||||
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())
|
||||
Reference in New Issue
Block a user