472 lines
16 KiB
Python
472 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Lint script for the Brand Build Skills catalog.
|
|
|
|
Validates structural conventions documented in SKILL_AUTHORING.md and CONTRIBUTING.md.
|
|
Run from the repo root via `python .github/scripts/lint_skills.py`.
|
|
|
|
Exit codes:
|
|
0 All checks passed.
|
|
1 One or more checks failed.
|
|
|
|
Designed to be:
|
|
- Stdlib-only except for PyYAML (frontmatter parsing).
|
|
- Loud about failures, quiet about passes.
|
|
- Easy to extend: each check is a function appended to CHECKS.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Callable, Iterable
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("ERROR: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SKILLS_DIR = REPO_ROOT / "skills"
|
|
README = REPO_ROOT / "README.md"
|
|
|
|
# Brand watchlist. Any case-insensitive hit fails the build, except for the
|
|
# explicitly-allowed legitimate public references in ALLOWED_BRAND_CONTEXTS.
|
|
BRAND_WATCHLIST = [
|
|
"adunn08",
|
|
"GameDesk",
|
|
"ANDY.md",
|
|
"bankranked",
|
|
"wirly",
|
|
"vehiclemd",
|
|
"carcabin",
|
|
"codeblu",
|
|
"stayrentals",
|
|
"insurrates",
|
|
"mycarneedsthis",
|
|
"tereks",
|
|
"checkvin",
|
|
"credit-factor",
|
|
"straightstocks",
|
|
"econgrader",
|
|
"degreemath",
|
|
"taxgrader",
|
|
"retiregrader",
|
|
"smallbizgrader",
|
|
"bankscored",
|
|
"401klens",
|
|
"diplomavalue",
|
|
"degreegrade",
|
|
"studentdebtmath",
|
|
"nesteggnerd",
|
|
"bizmoneymath",
|
|
"retireranked",
|
|
"taxscoped",
|
|
]
|
|
|
|
# `rampstack` requires special handling: legitimate as the public org name
|
|
# `rampstackco` and the public domain `rampstack.co`, never as `rampstack`
|
|
# in isolation. The check enforces this with a regex below.
|
|
|
|
SKILL_MD_LINE_LIMIT = 500 # Hard cap; warns above 400.
|
|
SKILL_MD_LINE_TARGET = 400
|
|
REFERENCE_LINE_LIMIT = 500
|
|
|
|
|
|
@dataclass
|
|
class LintResult:
|
|
errors: list[str] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
def fail(self, message: str) -> None:
|
|
self.errors.append(message)
|
|
|
|
def warn(self, message: str) -> None:
|
|
self.warnings.append(message)
|
|
|
|
def merge(self, other: "LintResult") -> None:
|
|
self.errors.extend(other.errors)
|
|
self.warnings.extend(other.warnings)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return not self.errors
|
|
|
|
|
|
def list_skill_dirs() -> list[Path]:
|
|
return sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir())
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def parse_frontmatter(skill_md: Path) -> tuple[dict, str]:
|
|
"""Return (frontmatter_dict, body_text) or raise ValueError on malformed YAML."""
|
|
text = read_text(skill_md)
|
|
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
|
|
if not match:
|
|
raise ValueError("No YAML frontmatter delimited by --- markers")
|
|
frontmatter_text, body = match.group(1), match.group(2)
|
|
try:
|
|
data = yaml.safe_load(frontmatter_text)
|
|
except yaml.YAMLError as exc:
|
|
raise ValueError(f"YAML parse error: {exc}")
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Frontmatter is not a dict")
|
|
return data, body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_em_dashes(result: LintResult) -> None:
|
|
"""Em dashes are forbidden anywhere in tracked Markdown."""
|
|
for md in REPO_ROOT.rglob("*.md"):
|
|
# Skip git internal and node_modules if any.
|
|
if any(part in (".git", "node_modules") for part in md.parts):
|
|
continue
|
|
text = read_text(md)
|
|
if "\u2014" in text:
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
if "\u2014" in line:
|
|
result.fail(
|
|
f"Em dash found in {md.relative_to(REPO_ROOT)}:{i}"
|
|
)
|
|
|
|
|
|
def check_brand_leaks(result: LintResult) -> None:
|
|
"""Forbidden brand mentions from the watchlist."""
|
|
for md in REPO_ROOT.rglob("*.md"):
|
|
if any(part in (".git", "node_modules") for part in md.parts):
|
|
continue
|
|
text = read_text(md)
|
|
|
|
text_lower = text.lower()
|
|
for needle in BRAND_WATCHLIST:
|
|
if needle.lower() in text_lower:
|
|
result.fail(
|
|
f"Brand watchlist hit '{needle}' in {md.relative_to(REPO_ROOT)}"
|
|
)
|
|
|
|
|
|
def check_frontmatter_and_name_match(result: LintResult) -> None:
|
|
"""Every SKILL.md must have valid frontmatter; name must match folder name."""
|
|
for skill_dir in list_skill_dirs():
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if not skill_md.exists():
|
|
result.fail(f"Missing SKILL.md in {skill_dir.relative_to(REPO_ROOT)}")
|
|
continue
|
|
try:
|
|
fm, _ = parse_frontmatter(skill_md)
|
|
except ValueError as exc:
|
|
result.fail(f"{skill_md.relative_to(REPO_ROOT)}: {exc}")
|
|
continue
|
|
name = fm.get("name")
|
|
description = fm.get("description")
|
|
if not name:
|
|
result.fail(f"{skill_md.relative_to(REPO_ROOT)}: missing 'name' in frontmatter")
|
|
elif name != skill_dir.name:
|
|
result.fail(
|
|
f"{skill_md.relative_to(REPO_ROOT)}: frontmatter name '{name}' "
|
|
f"does not match folder name '{skill_dir.name}'"
|
|
)
|
|
if not description:
|
|
result.fail(f"{skill_md.relative_to(REPO_ROOT)}: missing 'description' in frontmatter")
|
|
elif len(description.strip()) < 80:
|
|
result.warn(
|
|
f"{skill_md.relative_to(REPO_ROOT)}: description is short "
|
|
f"({len(description.strip())} chars); aim for 2-4 sentences"
|
|
)
|
|
|
|
|
|
def check_framework_section(result: LintResult) -> None:
|
|
"""Every SKILL.md must contain a section starting with `## The framework`."""
|
|
pattern = re.compile(r"^## The framework(:|\s|$)", re.MULTILINE)
|
|
for skill_dir in list_skill_dirs():
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if not skill_md.exists():
|
|
continue
|
|
text = read_text(skill_md)
|
|
if not pattern.search(text):
|
|
result.fail(
|
|
f"{skill_md.relative_to(REPO_ROOT)}: missing '## The framework' "
|
|
f"section (canonical text is required; descriptive colon-suffix is allowed)"
|
|
)
|
|
|
|
|
|
def extract_reference_section(text: str) -> str | None:
|
|
"""Extract the body text under the actual `## Reference files` heading.
|
|
|
|
Skips matches inside fenced code blocks. Uses the last canonical occurrence
|
|
in case the heading is also referenced inside a code-block template.
|
|
Returns None if the section is not found.
|
|
"""
|
|
lines = text.splitlines()
|
|
in_code_fence = False
|
|
section_starts: list[int] = []
|
|
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if stripped.startswith("```"):
|
|
in_code_fence = not in_code_fence
|
|
continue
|
|
if not in_code_fence and stripped == "## Reference files":
|
|
section_starts.append(i)
|
|
|
|
if not section_starts:
|
|
return None
|
|
|
|
start = section_starts[-1]
|
|
|
|
in_code_fence = False
|
|
section_lines: list[str] = []
|
|
for i in range(start + 1, len(lines)):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
if stripped.startswith("```"):
|
|
in_code_fence = not in_code_fence
|
|
section_lines.append(line)
|
|
continue
|
|
if not in_code_fence and stripped.startswith("## "):
|
|
break
|
|
section_lines.append(line)
|
|
|
|
return "\n".join(section_lines)
|
|
|
|
|
|
def check_reference_files_match(result: LintResult) -> None:
|
|
"""SKILL.md 'Reference files' list must match actual files in references/.
|
|
|
|
Only the `## Reference files` section is parsed. References mentioned
|
|
elsewhere (e.g., naming-convention examples in the body, or the canonical
|
|
skill template inside a code block) are ignored.
|
|
"""
|
|
ref_pattern = re.compile(r"`references/([^`]+)`")
|
|
|
|
for skill_dir in list_skill_dirs():
|
|
skill_md = skill_dir / "SKILL.md"
|
|
ref_dir = skill_dir / "references"
|
|
if not skill_md.exists():
|
|
continue
|
|
|
|
text = read_text(skill_md)
|
|
section_text = extract_reference_section(text)
|
|
if section_text is None:
|
|
result.fail(
|
|
f"{skill_md.relative_to(REPO_ROOT)}: missing '## Reference files' section"
|
|
)
|
|
continue
|
|
cited_refs = set(ref_pattern.findall(section_text))
|
|
|
|
actual_refs = set()
|
|
if ref_dir.exists():
|
|
actual_refs = {p.name for p in ref_dir.iterdir() if p.is_file()}
|
|
|
|
missing = cited_refs - actual_refs
|
|
orphan = actual_refs - cited_refs
|
|
|
|
rel = skill_md.relative_to(REPO_ROOT)
|
|
for name in sorted(missing):
|
|
result.fail(f"{rel}: cites references/{name} but file does not exist")
|
|
for name in sorted(orphan):
|
|
result.fail(
|
|
f"{skill_dir.relative_to(REPO_ROOT)}/references/{name}: "
|
|
f"orphan file not cited from SKILL.md"
|
|
)
|
|
|
|
|
|
def check_cross_skill_references(result: LintResult) -> None:
|
|
"""Cross-skill references like `skill-name` must name skills that exist."""
|
|
skill_names = {p.name for p in list_skill_dirs()}
|
|
# Catch backtick-quoted skill names that look like skill folder names
|
|
# (lowercase-hyphenated, must contain at least one hyphen to avoid noise).
|
|
pattern = re.compile(r"`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`")
|
|
|
|
for skill_dir in list_skill_dirs():
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if not skill_md.exists():
|
|
continue
|
|
text = read_text(skill_md)
|
|
for match in pattern.finditer(text):
|
|
candidate = match.group(1)
|
|
# Heuristic: only check tokens that look like full skill names
|
|
# (start with one of our known prefixes or are exactly a skill name).
|
|
if candidate in skill_names:
|
|
continue
|
|
# Looks-like-skill heuristic: has 2+ hyphens or starts with known prefix.
|
|
known_prefixes = (
|
|
"seo-", "brand-", "content-", "design-", "code-", "frontend-",
|
|
"qa-", "ux-", "pm-", "cro-", "form-", "media-", "email-",
|
|
"domain-", "monitoring-", "backup-", "security-", "launch-",
|
|
"incident-", "after-", "analytics-", "journey-", "usability-",
|
|
"performance-", "accessibility-", "art-", "creative-",
|
|
"information-", "landing-", "skill-", "stakeholder-",
|
|
"documentation-", "vendor-", "team-", "internationalization",
|
|
"dependency-", "cost-", "roadmap-",
|
|
)
|
|
if any(candidate.startswith(p) for p in known_prefixes):
|
|
result.fail(
|
|
f"{skill_md.relative_to(REPO_ROOT)}: references unknown skill "
|
|
f"`{candidate}` (not a valid skill folder name)"
|
|
)
|
|
|
|
|
|
def check_line_lengths(result: LintResult) -> None:
|
|
"""SKILL.md soft cap 400, hard cap 500. Reference files cap 500.
|
|
|
|
Counts file line counts via len(splitlines()), not characters
|
|
per line. The function name is misleading shorthand for file
|
|
line counts. Consider renaming in a future cleanup.
|
|
"""
|
|
for skill_dir in list_skill_dirs():
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if skill_md.exists():
|
|
n = len(read_text(skill_md).splitlines())
|
|
rel = skill_md.relative_to(REPO_ROOT)
|
|
if n > SKILL_MD_LINE_LIMIT:
|
|
result.fail(f"{rel}: {n} lines (hard cap is {SKILL_MD_LINE_LIMIT})")
|
|
elif n > SKILL_MD_LINE_TARGET:
|
|
result.warn(f"{rel}: {n} lines (target is under {SKILL_MD_LINE_TARGET})")
|
|
ref_dir = skill_dir / "references"
|
|
if ref_dir.exists():
|
|
for ref in ref_dir.iterdir():
|
|
if not ref.is_file() or ref.suffix != ".md":
|
|
continue
|
|
n = len(read_text(ref).splitlines())
|
|
rel = ref.relative_to(REPO_ROOT)
|
|
if n > REFERENCE_LINE_LIMIT:
|
|
result.warn(f"{rel}: {n} lines (target is under {REFERENCE_LINE_LIMIT})")
|
|
|
|
|
|
def check_readme_catalog_generated(result: LintResult) -> None:
|
|
"""README catalog content must match the output of the generator script.
|
|
|
|
Runs `scripts/generate_readme_catalog.py --check` and surfaces its diff
|
|
on failure. This is the strict version of `check_readme_catalog_count`,
|
|
which is kept as a sanity check for badge and header counts.
|
|
"""
|
|
import subprocess
|
|
|
|
generator = REPO_ROOT / "scripts" / "generate_readme_catalog.py"
|
|
if not generator.exists():
|
|
result.fail(
|
|
f"generator script not found at {generator.relative_to(REPO_ROOT)}"
|
|
)
|
|
return
|
|
proc = subprocess.run(
|
|
[sys.executable, str(generator), "--check"],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if proc.returncode == 0:
|
|
return
|
|
detail = (proc.stdout or proc.stderr or "").strip()
|
|
result.fail(
|
|
"README catalog out of sync with skill metadata. "
|
|
"Run: python scripts/generate_readme_catalog.py --write"
|
|
+ (f"\n{detail}" if detail else "")
|
|
)
|
|
|
|
|
|
def check_readme_catalog_count(result: LintResult) -> None:
|
|
"""README badge and catalog claims must match the actual SKILL.md count."""
|
|
if not README.exists():
|
|
result.fail("README.md not found")
|
|
return
|
|
text = read_text(README)
|
|
actual = len(list_skill_dirs())
|
|
|
|
# Badge: []
|
|
badge_pattern = re.compile(r"badge/Skills-(\d+)-")
|
|
for match in badge_pattern.finditer(text):
|
|
claimed = int(match.group(1))
|
|
if claimed != actual:
|
|
result.fail(
|
|
f"README.md skills badge claims {claimed} but actual count is {actual}"
|
|
)
|
|
|
|
# Catalog header: e.g. "## The 59-skill catalog"
|
|
catalog_pattern = re.compile(r"## The (\d+)-skill catalog")
|
|
for match in catalog_pattern.finditer(text):
|
|
claimed = int(match.group(1))
|
|
if claimed != actual:
|
|
result.fail(
|
|
f"README.md catalog header claims {claimed} but actual count is {actual}"
|
|
)
|
|
|
|
# Status line: "Status: N of N skills shipped" or similar
|
|
status_pattern = re.compile(r"(\d+) of (\d+) skills shipped", re.IGNORECASE)
|
|
for match in status_pattern.finditer(text):
|
|
claimed_done, claimed_total = int(match.group(1)), int(match.group(2))
|
|
if claimed_total != actual:
|
|
result.fail(
|
|
f"README.md status claims '{claimed_done} of {claimed_total} skills' "
|
|
f"but actual count is {actual}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Driver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
CHECKS: list[Callable[[LintResult], None]] = [
|
|
check_em_dashes,
|
|
check_brand_leaks,
|
|
check_frontmatter_and_name_match,
|
|
check_framework_section,
|
|
check_reference_files_match,
|
|
check_cross_skill_references,
|
|
check_line_lengths,
|
|
check_readme_catalog_count,
|
|
check_readme_catalog_generated,
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
print(f"Linting catalog at {REPO_ROOT}")
|
|
if not SKILLS_DIR.exists():
|
|
print(f"ERROR: skills/ directory not found at {SKILLS_DIR}", file=sys.stderr)
|
|
return 1
|
|
|
|
skill_count = len(list_skill_dirs())
|
|
print(f"Found {skill_count} skill folders.\n")
|
|
|
|
result = LintResult()
|
|
for check in CHECKS:
|
|
sub = LintResult()
|
|
try:
|
|
check(sub)
|
|
except Exception as exc:
|
|
sub.fail(f"Internal error in {check.__name__}: {exc}")
|
|
status = "OK" if sub.ok else "FAIL"
|
|
warn_count = len(sub.warnings)
|
|
warn_suffix = f" ({warn_count} warnings)" if warn_count else ""
|
|
print(f" [{status}] {check.__name__}{warn_suffix}")
|
|
for err in sub.errors:
|
|
print(f" ERROR: {err}")
|
|
for warn in sub.warnings:
|
|
print(f" WARN: {warn}")
|
|
result.merge(sub)
|
|
|
|
print()
|
|
if result.ok:
|
|
print(f"PASSED: {skill_count} skills validated, "
|
|
f"{len(result.warnings)} warnings.")
|
|
return 0
|
|
print(f"FAILED: {len(result.errors)} errors, "
|
|
f"{len(result.warnings)} warnings across {skill_count} skills.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|