chore: import upstream snapshot with attribution
Lint skills / Validate catalog structure (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:36:55 +08:00
commit e011bb26cd
1260 changed files with 231256 additions and 0 deletions
+471
View File
@@ -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: [![Skills](https://img.shields.io/badge/Skills-59-blue.svg)]
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())