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
+95
View File
@@ -0,0 +1,95 @@
name: Bug report
description: Report a bug, broken link, broken trigger, or content error in a skill or reference file.
title: "[Bug]: <short description>"
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug. Please fill in as much detail as you can.
**Not a bug?** Use these instead:
- General questions or proposals: [Discussions](https://github.com/rampstackco/claude-skills/discussions)
- Security issues: see [SECURITY.md](../blob/main/SECURITY.md)
- Improvement to a reference file: use the "Improve a reference file" template
- type: input
id: skill
attributes:
label: Skill affected
description: Which skill is the bug in? Use the skill folder name. If the bug is in the README, CONTRIBUTING.md, or another root doc, name that file instead.
placeholder: seo-traffic-diagnosis
validations:
required: true
- type: input
id: file
attributes:
label: Specific file (if applicable)
description: If the bug is in a specific reference file, name it.
placeholder: references/diagnosis-checklist.md
- type: dropdown
id: bug-type
attributes:
label: Type of bug
options:
- "Broken link (internal cross-reference or external URL)"
- "Trigger doesn't fire when it should"
- "Trigger fires when it shouldn't (false positive)"
- "Factual error in content"
- "Em dash or formatting violation"
- "Em dash in catalog count or stats"
- "Skill output doesn't match documented format"
- "Cross-skill reference points to non-existent skill"
- "YAML frontmatter parse error"
- "Other"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What is the bug? Be specific.
placeholder: |
The skill `seo-traffic-diagnosis` references `references/diagnoses-checklist.md` (typo) but the actual file is `references/diagnosis-checklist.md`.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen instead?
placeholder: |
The Reference files section in SKILL.md should match the actual filename in the references/ folder.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: How did you find this? If applicable, include the exact prompt that triggered the wrong behavior.
placeholder: |
1. Opened `skills/seo-traffic-diagnosis/SKILL.md`
2. Searched for "diagnoses" (typo) and "diagnosis"
3. Found mismatch between the SKILL.md text and the actual file in references/
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else worth knowing? Screenshots welcome.
- type: dropdown
id: willingness
attributes:
label: Are you willing to submit a fix?
options:
- "Yes, I will draft a PR"
- "Maybe, depending on complexity"
- "No, just reporting"
validations:
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Question or proposal
url: https://github.com/rampstackco/claude-skills/discussions
about: For general questions, design discussions, or proposals, please open a Discussion instead of an Issue.
- name: Security issue
url: https://github.com/rampstackco/claude-skills/security/advisories/new
about: Report security vulnerabilities privately via GitHub Security Advisories. See SECURITY.md for the full policy.
- name: Documentation
url: https://github.com/rampstackco/claude-skills/blob/main/SKILL_AUTHORING.md
about: Read SKILL_AUTHORING.md and CONTRIBUTING.md before opening a new-skill issue.
@@ -0,0 +1,104 @@
name: Improve a reference file
description: Suggest an improvement to a reference file (template, checklist, decision matrix, worked example).
title: "[Improve]: <skill-name> / <reference-file-name>"
labels: ["improvement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement. Reference files are the deep-dive material that go beyond what fits in a SKILL.md. They get better with every well-shaped contribution.
Before filling this out, please confirm:
- You have read the existing reference file end to end
- You have read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md)
- The improvement does not belong in the SKILL.md instead
- type: input
id: skill
attributes:
label: Skill
description: Which skill folder does the reference file belong to?
placeholder: seo-keyword-gap-audit
validations:
required: true
- type: input
id: reference-file
attributes:
label: Reference file
description: Which file in the references/ folder are you improving?
placeholder: references/opportunity-scoring-rubric.md
validations:
required: true
- type: dropdown
id: improvement-type
attributes:
label: Type of improvement
options:
- "Add a worked example"
- "Add a missing edge case"
- "Update outdated guidance"
- "Improve a template or checklist"
- "Add a missing reference (table, formula, decision tree)"
- "Fix factual error"
- "Improve clarity or structure"
- "Other"
validations:
required: true
- type: textarea
id: gap
attributes:
label: What is missing or wrong?
description: Describe the gap specifically. Quote the existing text if you can.
placeholder: |
The opportunity-scoring-rubric.md does not cover [specific scenario]. The current scoring formula assumes [assumption], but this breaks down when [edge case].
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: |
Sketch the addition or change. Doesn't have to be final wording, but should be specific enough to evaluate.
placeholder: |
Add a section "Adjusting weights for goal" with three preset weight configurations:
- Quick wins focus
- Strategic territory focus
- Defensive (protect rankings)
validations:
required: true
- type: textarea
id: justification
attributes:
label: Why does this earn its place?
description: |
Reference files should go deeper than the SKILL.md, not duplicate it, and should be useful in real work.
placeholder: |
Without this, users running an audit on a low-DR site cannot calibrate the rubric correctly and the priority list mis-ranks. I have hit this in two real audits.
- type: dropdown
id: willingness
attributes:
label: Are you willing to draft the change?
options:
- "Yes, I will draft a PR"
- "I can collaborate on a PR"
- "I am only suggesting, not authoring"
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Confirmation
options:
- label: I have read the existing reference file end to end
required: true
- label: I confirmed this content does not belong in the SKILL.md instead
required: true
- label: I have read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md)
required: true
+113
View File
@@ -0,0 +1,113 @@
name: Propose a new skill
description: Suggest a new skill for the library. Read CONTRIBUTING.md first.
title: "[New skill]: <proposed-skill-name>"
labels: ["new-skill", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for proposing a new skill. Before filling this out, please confirm:
- You have read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and [SKILL_AUTHORING.md](../blob/main/SKILL_AUTHORING.md)
- You have searched the [existing skills](../tree/main/skills) and confirmed this gap is real
- The proposed skill is stack-agnostic (or has a strong reason to be tool-bound)
- type: input
id: proposed-name
attributes:
label: Proposed skill name
description: Use lowercase-hyphenated format. Example: `seo-rank-tracking`
placeholder: my-new-skill
validations:
required: true
- type: textarea
id: description
attributes:
label: Draft description (frontmatter)
description: |
2-4 sentences. Should include:
- One sentence: what the skill does
- One sentence: situations where it should fire
- Trigger words and phrases
- An "Also triggers when..." edge case line
placeholder: |
Audits X for Y using Z. Use this skill when [specific situation], scoping a [related task], or [another situation]. Triggers on [phrase 1], [phrase 2], [phrase 3]. Also triggers when [edge case].
validations:
required: true
- type: textarea
id: gap
attributes:
label: Why existing skills don't cover this
description: Name the closest existing skills and explain why they fall short for this use case.
placeholder: |
- `existing-skill-a` covers part of this but stops at...
- `existing-skill-b` is adjacent but focuses on...
- There is no skill that handles [specific need].
validations:
required: true
- type: textarea
id: triggers
attributes:
label: Example user prompts that should trigger this skill
description: List 3-5 realistic prompts a user would type that should fire this skill.
placeholder: |
- "Help me audit X for Y"
- "Why is Z happening to my A"
- "Compare B options across C"
validations:
required: true
- type: textarea
id: framework
attributes:
label: The framework
description: |
What is the durable insight the skill teaches? 1-3 sentences. If you cannot articulate this, the skill is not yet ready.
placeholder: |
There are N categories of X, and they each need different treatment. Phase 1 is..., phase 2 is..., phase 3 is...
validations:
required: true
- type: textarea
id: references
attributes:
label: Reference files
description: What templates, checklists, or worked examples will live in `references/`?
placeholder: |
- `template.md` - Blank template for the deliverable
- `checklist.md` - Pre-flight checklist
- `example.md` - Worked example for [scenario]
- type: textarea
id: pairs-with
attributes:
label: Pairs with which existing skills?
description: Which sibling skills will this cross-reference in "When NOT to use" or workflow sections?
placeholder: |
- `skill-a` - for adjacent task X
- `skill-b` - for downstream task Y
- type: dropdown
id: willingness
attributes:
label: Are you willing to author the skill?
options:
- "Yes, I will draft a PR"
- "I can collaborate on a PR"
- "I am only proposing, not authoring"
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Confirmation
options:
- label: I have read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and [SKILL_AUTHORING.md](../blob/main/SKILL_AUTHORING.md)
required: true
- label: I confirmed this skill is not duplicating an existing one
required: true
- label: I understand the proposal may be declined if the gap is not real or the framework is not durable
required: true
+73
View File
@@ -0,0 +1,73 @@
<!-- Thanks for opening a PR. Please fill in the sections below. -->
## What this PR does
<!-- One paragraph. What is the change and why? -->
## Type of change
<!-- Check the one that applies. -->
- [ ] New skill (adds a folder under `skills/`)
- [ ] Update to an existing skill (`SKILL.md` body or frontmatter)
- [ ] Update to a reference file
- [ ] Bug fix (broken link, typo, formatting violation, etc.)
- [ ] Documentation (README, CONTRIBUTING, SKILL_AUTHORING, MAPPING, CHANGELOG)
- [ ] Tooling or CI
## Linked issue
<!-- Link the issue this PR addresses. New skills should always have an issue first. -->
Closes #
---
## Contributor checklist
<!-- Confirm each item before requesting review. Some items only apply to certain change types. -->
### For all changes
- [ ] I have read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md)
- [ ] No em dashes anywhere in the changed files (search for U+2014)
- [ ] No private brand, personal handle, or proprietary domain references
- [ ] Internal links resolve to real files
- [ ] Cross-references to sibling skills name skills that actually exist
- [ ] PR title follows the format: `Add: <name>` / `Fix: <description>` / `Improve: <skill> reference` / `Update: <doc>`
### If adding a new skill
- [ ] Skill folder name is `lowercase-hyphenated`
- [ ] `SKILL.md` exists with valid YAML frontmatter
- [ ] `name` in frontmatter matches the folder name exactly
- [ ] `description` is 2-4 sentences with explicit triggers and an "Also triggers when..." line
- [ ] All 10 sections are present in the documented order (When to use, When NOT to use, Required inputs, The framework, Workflow, Failure patterns, Output format, Reference files)
- [ ] At least one reference file exists in `references/`
- [ ] `Reference files` section in SKILL.md matches the actual files in `references/`
- [ ] No vendor-specific framework references in the SKILL.md body
- [ ] No version-specific references (e.g., "Tailwind 4", "Next.js 15") in the SKILL.md body
- [ ] `SKILL.md` is under 250 lines (hard cap 500)
- [ ] Each reference file is under 400 lines
- [ ] I added the new skill to the catalog table in `README.md`
- [ ] I added an entry to `CHANGELOG.md` under `[Unreleased]`
- [ ] I ran a quick check for trigger collisions with existing skills
### If modifying an existing skill
- [ ] Changes preserve the uniform section order
- [ ] If renaming files, all references in SKILL.md are updated
- [ ] If renaming the skill, all cross-references in other SKILL.md files are updated
- [ ] I added an entry to `CHANGELOG.md` under `[Unreleased]` if the change is user-visible
### If fixing a bug
- [ ] I described the bug in the linked issue or in this PR
- [ ] I verified the fix actually resolves the problem
- [ ] I checked for the same bug pattern elsewhere in the repo
---
## Notes for the reviewer
<!-- Anything that does not fit above. Trade-offs you considered. Things you are unsure about. -->
+22
View File
@@ -0,0 +1,22 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
# Keep GitHub Actions versions current. The repo's lint workflow uses
# actions/checkout and actions/setup-python; when a new major version
# ships, Dependabot opens a PR with release notes attached.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/Denver"
open-pull-requests-limit: 5
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
+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())
+27
View File
@@ -0,0 +1,27 @@
name: Lint skills
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
lint:
name: Validate catalog structure
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
run: pip install pyyaml
- name: Run skill linter
run: python .github/scripts/lint_skills.py
+18
View File
@@ -0,0 +1,18 @@
name: Skills manifest
on:
pull_request:
paths:
- "skills/**"
- "tools/gen_skills_lock.py"
jobs:
verify-lock:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Verify SKILLS.lock is current
run: python tools/gen_skills_lock.py --check
+56
View File
@@ -0,0 +1,56 @@
name: Workflows manifest
on:
pull_request:
paths:
- "workflows/**"
- "SKILLS.lock"
- "tools/gen_workflows_lock.py"
- "tools/check_workflow_drift.py"
- ".github/workflows/workflows-manifest.yml"
jobs:
verify-lock:
name: WORKFLOWS.lock is current
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Verify WORKFLOWS.lock
run: python tools/gen_workflows_lock.py --check
drift-check:
name: Bound skills exist in SKILLS.lock
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check workflow-to-catalog drift
run: python tools/check_workflow_drift.py
fence-test:
name: SKILLS.lock untouched by the workflows tier
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Assert the fence
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
touches_workflows=$(git diff --name-only "$BASE_SHA" HEAD -- \
workflows/ tools/gen_workflows_lock.py tools/check_workflow_drift.py)
skills_lock_changed=$(git diff --name-only "$BASE_SHA" HEAD -- SKILLS.lock)
if [ -n "$touches_workflows" ] && [ -n "$skills_lock_changed" ]; then
echo "FENCE BREACH: this PR changes both the workflows tier and SKILLS.lock."
echo "The workflows tier lives outside the skills source tree and must not"
echo "perturb the skills manifest. Split the skill change into its own PR."
git diff --stat "$BASE_SHA" HEAD -- SKILLS.lock
exit 1
fi
echo "Fence holds: SKILLS.lock is byte-identical to base for this workflows-tier PR."