← Files DesignlyARCHIVED FILE

skills/campaign-canon/scripts/validate_schema.py

8.84 KB · Sep 30, 2026 · 23:14 UTC

↓ Download file

# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///

"""Validate frontmatter cards against tag-schema.md."""

import argparse
import re
import sys
from pathlib import Path
import yaml

SKILL_ROOT = Path(__file__).parent.parent
SCHEMA_PATH = SKILL_ROOT / "references" / "tag-schema.md"
CARDS_DIR = SKILL_ROOT / "references" / "legendary-campaigns" / "cards"

REQUIRED_FIELDS = [
    "id", "title", "brand", "year", "country", "region", "industry",
    "pattern", "category", "idea_type", "involvement", "channel", "duration",
    "goal", "budget", "emotion", "emotion_tier", "insight_domain",
    "media_epoch", "awards", "quality_score", "scalability", "risk",
]

# Cardinality: field -> (min, max) for list fields
CARDINALITY: dict[str, tuple[int, int]] = {
    "pattern": (1, 3),
    "goal": (1, 4),
    "emotion": (1, 4),
    "awards": (0, 4),
}

# Fields that are free-form strings or validated by other means
SKIP_ENUM: set[str] = {"id", "title", "brand", "agency", "source_url", "emotion"}

# Optional/meta fields and their expected types
OPTIONAL_TYPES: dict[str, type] = {
    "agency": str,
    "confidence": str,
    "verification_required": bool,
    "quality": str,
    "source_url": str,
}

OPTIONAL_ENUMS: dict[str, set[str]] = {
    "confidence": {"high", "medium", "low"},
    "quality": {"canonical", "full", "stub_enriched", "stub_minimal"},
}

ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*-\d{4}(?:-\d+)?$")


def parse_schema(schema_path: Path) -> dict[str, set[str]]:
    """Parse tag-schema.md and return field -> set of allowed enum values."""
    text = schema_path.read_text(encoding="utf-8")
    schema: dict[str, set[str]] = {}

    # country: any 2-letter uppercase ISO code + Global + unknown
    schema["country"] = {f"{a}{b}" for a in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for b in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}
    schema["country"].update({"Global", "unknown"})

    # region: bullet list
    region_match = re.search(
        r"### `region`\n((?:- `?\w+`?\n?)+)", text
    )
    if region_match:
        schema["region"] = set(re.findall(r"- `?(\w+)`?", region_match.group(1)))

    # industry: bullet list (strip parenthetical notes)
    industry_match = re.search(
        r"### `industry`\n((?:- `?[\w_]+`?.*\n?)+)", text
    )
    if industry_match:
        schema["industry"] = set(
            re.findall(r"- `?([\w_]+)`?", industry_match.group(1))
        )

    # pattern: P01-P18 bullets
    pattern_match = re.search(
        r"### `pattern`.*?\n((?:- `?(P\d{2})`?.*\n?)+)", text
    )
    if pattern_match:
        schema["pattern"] = set(re.findall(r"(P\d{2})", pattern_match.group(1)))

    # Simple single-word bullet enums
    simple_fields = [
        "category", "idea_type", "involvement", "channel", "duration",
        "goal", "budget", "insight_domain", "media_epoch", "scalability", "risk",
    ]
    for field in simple_fields:
        m = re.search(
            rf"### `{field}`[^\n]*\n((?:- `?[\w_]+`?.*\n?)+)", text
        )
        if m:
            schema[field] = set(re.findall(r"- `?([\w_]+)`?", m.group(1)))

    # awards: multi-word slugs
    awards_match = re.search(
        r"### `awards`\n((?:- `?[\w_]+`?.*\n?)+)", text
    )
    if awards_match:
        schema["awards"] = set(re.findall(r"- `?([\w_]+)`?", awards_match.group(1)))

    return schema


def validate_card(card_path: Path, schema: dict[str, set[str]]) -> list[str]:
    """Validate a single card's frontmatter. Returns list of error strings."""
    errors: list[str] = []
    text = card_path.read_text(encoding="utf-8")

    if not text.startswith("---"):
        errors.append("no frontmatter")
        return errors

    # Extract YAML block between first pair of ---
    parts = text.split("---", 2)
    if len(parts) < 3:
        errors.append("no frontmatter")
        return errors

    try:
        fm = yaml.safe_load(parts[1])
    except yaml.YAMLError as exc:
        errors.append(f"yaml parse error: {exc}")
        return errors

    if not isinstance(fm, dict):
        errors.append("frontmatter is not a mapping")
        return errors

    # --- Required fields ---
    for field in REQUIRED_FIELDS:
        if field not in fm or fm[field] is None:
            errors.append(f"Missing required field: {field}")

    # --- year ---
    if "year" in fm and fm["year"] is not None:
        y = fm["year"]
        if not isinstance(y, int) or not (1950 <= y <= 2025):
            errors.append(f"Invalid `year`: {y!r} (must be int 1950–2025)")

    # --- quality_score ---
    if "quality_score" in fm and fm["quality_score"] is not None:
        qs = fm["quality_score"]
        if not isinstance(qs, int) or not (1 <= qs <= 10):
            errors.append(f"Invalid `quality_score`: {qs!r} (must be int 1–10)")

    # --- emotion_tier ---
    if "emotion_tier" in fm and fm["emotion_tier"] is not None:
        et = fm["emotion_tier"]
        if et not in (1, 2, 3):
            errors.append(f"Invalid `emotion_tier`: {et!r} (must be 1, 2, or 3)")

    # --- id format ---
    if "id" in fm and fm["id"] is not None:
        id_val = str(fm["id"])
        if not ID_RE.match(id_val):
            errors.append(
                f"Invalid `id` format: {id_val!r} "
                "(expected kebab-case ending with 4-digit year)"
            )

    # --- enum + cardinality validation ---
    for field, allowed in schema.items():
        if field in SKIP_ENUM or fm.get(field) is None:
            continue

        value = fm[field]
        card = CARDINALITY.get(field)

        if card is not None:
            # Expect a list
            if not isinstance(value, list):
                errors.append(
                    f"Field `{field}` must be a list, got {type(value).__name__}"
                )
                continue
            min_c, max_c = card
            if not (min_c <= len(value) <= max_c):
                errors.append(
                    f"Field `{field}` cardinality {len(value)} "
                    f"out of range [{min_c}–{max_c}]"
                )
            for v in value:
                if str(v) not in allowed:
                    errors.append(
                        f"Invalid value for `{field}`: {v!r} (not in allowed set)"
                    )
        else:
            # Expect a scalar
            if isinstance(value, list):
                errors.append(
                    f"Field `{field}` must be a single value, not a list"
                )
                continue
            if str(value) not in allowed:
                errors.append(
                    f"Invalid value for `{field}`: {value!r} (not in allowed set)"
                )

    # --- emotion: must be list of strings ---
    if "emotion" in fm and fm["emotion"] is not None:
        em = fm["emotion"]
        card = CARDINALITY["emotion"]
        if not isinstance(em, list):
            errors.append(f"Field `emotion` must be a list, got {type(em).__name__}")
        else:
            if not (card[0] <= len(em) <= card[1]):
                errors.append(
                    f"Field `emotion` cardinality {len(em)} out of range [1–4]"
                )
            for v in em:
                if not isinstance(v, str):
                    errors.append(f"Field `emotion` values must be strings, got {v!r}")

    # --- optional fields type/enum check ---
    for field, expected_type in OPTIONAL_TYPES.items():
        if field not in fm or fm[field] is None:
            continue
        val = fm[field]
        if not isinstance(val, expected_type):
            errors.append(
                f"Optional field `{field}`: expected {expected_type.__name__}, "
                f"got {type(val).__name__}"
            )
        if field in OPTIONAL_ENUMS and str(val) not in OPTIONAL_ENUMS[field]:
            errors.append(
                f"Invalid value for optional `{field}`: {val!r} "
                f"(allowed: {sorted(OPTIONAL_ENUMS[field])})"
            )

    return errors


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Validate creative-director cards against tag-schema.md"
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help="Exit with code 1 if any errors are found",
    )
    args = parser.parse_args()

    schema = parse_schema(SCHEMA_PATH)

    cards = sorted(CARDS_DIR.glob("*.md"))
    total = len(cards)
    valid = 0
    errored = 0

    for i, card_path in enumerate(cards, 1):
        if i % 50 == 0 or i == total:
            print(f"Checked {i}/{total} cards...")

        errors = validate_card(card_path, schema)
        if errors:
            errored += 1
            print(f"[ERROR] cards/{card_path.name}", file=sys.stderr)
            for err in errors:
                print(f"  - {err}", file=sys.stderr)
        else:
            valid += 1

    summary = f"Validated {total} cards: {valid} valid, {errored} with errors"
    print(summary)

    if args.strict and errored > 0:
        sys.exit(1)


if __name__ == "__main__":
    main()

SHA-256: f982e042af29b7ee55d0ea904751f7ec6a91af8759e6f773e815b75fa2e95ecb