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
762 lines
27 KiB
Python
Executable File
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 .
|
|
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())
|