chore: import upstream snapshot with attribution
Lint skills / Validate catalog structure (push) Has been cancelled
Lint skills / Validate catalog structure (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user