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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. -->
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user