feat: add pr rule & skill
This commit is contained in:
214
.claude/skills/pr-review/scripts/validate_skills.py
Normal file
214
.claude/skills/pr-review/scripts/validate_skills.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate skill directory structure and SKILL.md frontmatter.
|
||||
|
||||
Zero external dependencies — uses only Python standard library.
|
||||
Exit code 0: all checks passed (warnings are OK).
|
||||
Exit code 1: at least one ERROR found.
|
||||
|
||||
Usage:
|
||||
python validate_skills.py # scan default path (skills/)
|
||||
python validate_skills.py --path some/dir # scan specific directory
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal frontmatter parser
|
||||
# ---------------------------------------------------------------------------
|
||||
def extract_frontmatter(text):
|
||||
"""Extract YAML frontmatter string between --- markers. Returns None if not found."""
|
||||
stripped = text.lstrip("\ufeff")
|
||||
if not stripped.startswith("---"):
|
||||
return None
|
||||
end = stripped.find("---", 3)
|
||||
if end == -1:
|
||||
return None
|
||||
return stripped[3:end]
|
||||
|
||||
|
||||
def parse_frontmatter_fields(fm_text):
|
||||
"""Parse top-level scalar fields from frontmatter text.
|
||||
|
||||
Returns dict of {field_name: value_string}. Nested keys under a mapping
|
||||
are ignored — we only need top-level presence checks.
|
||||
"""
|
||||
fields = {}
|
||||
lines = fm_text.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if not line.strip() or line.strip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)", line)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
if rest in ("|", ">", "|+", "|-", ">+", ">-"):
|
||||
block_lines = []
|
||||
i += 1
|
||||
while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t") or lines[i].strip() == ""):
|
||||
block_lines.append(lines[i])
|
||||
i += 1
|
||||
fields[key] = "\n".join(block_lines).strip()
|
||||
continue
|
||||
elif rest == "":
|
||||
block_lines = []
|
||||
i += 1
|
||||
while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t")):
|
||||
block_lines.append(lines[i])
|
||||
i += 1
|
||||
fields[key] = "\n".join(block_lines).strip() if block_lines else ""
|
||||
continue
|
||||
else:
|
||||
fields[key] = rest.strip("\"'")
|
||||
i += 1
|
||||
return fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
SECRET_PATTERNS = [
|
||||
(r"sk-[a-zA-Z0-9]{20,}", "OpenAI-style API key"),
|
||||
(r"AKIA[0-9A-Z]{16}", "AWS access key"),
|
||||
(r"Bearer\s+[a-zA-Z0-9_\-\.]{50,}", "Hardcoded bearer token"),
|
||||
]
|
||||
|
||||
SCAN_EXTENSIONS = {".md", ".py", ".sh", ".js", ".ts", ".json", ".yaml", ".yml", ".txt", ".toml", ".cfg", ".ini"}
|
||||
|
||||
def scan_secrets(filepath):
|
||||
"""Scan a file for hardcoded secrets. Returns list of (line_no, pattern_desc, matched_text)."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for line_no, line in enumerate(content.splitlines(), 1):
|
||||
for pattern, desc in SECRET_PATTERNS:
|
||||
for match in re.finditer(pattern, line):
|
||||
findings.append((line_no, desc, match.group(0)[:60]))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill discovery and validation
|
||||
# ---------------------------------------------------------------------------
|
||||
def find_skill_dirs(base_path):
|
||||
"""Find directories that contain a SKILL.md."""
|
||||
skill_dirs = []
|
||||
for root, dirs, files in os.walk(base_path):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
if "SKILL.md" in files:
|
||||
skill_dirs.append(root)
|
||||
return sorted(skill_dirs)
|
||||
|
||||
|
||||
def validate_skill(skill_dir):
|
||||
"""Validate a single skill directory. Returns (errors, warnings) lists."""
|
||||
errors = []
|
||||
warnings = []
|
||||
dir_name = os.path.basename(skill_dir)
|
||||
skill_md = os.path.join(skill_dir, "SKILL.md")
|
||||
|
||||
if not os.path.isfile(skill_md):
|
||||
errors.append("SKILL.md not found")
|
||||
return errors, warnings
|
||||
|
||||
with open(skill_md, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
|
||||
fm_text = extract_frontmatter(content)
|
||||
if fm_text is None:
|
||||
errors.append("SKILL.md has no valid YAML frontmatter (missing --- markers)")
|
||||
return errors, warnings
|
||||
|
||||
fields = parse_frontmatter_fields(fm_text)
|
||||
|
||||
name = fields.get("name", "").strip()
|
||||
if not name:
|
||||
errors.append("Missing required field: name")
|
||||
elif name != dir_name:
|
||||
errors.append(f"name '{name}' does not match directory name '{dir_name}'")
|
||||
|
||||
desc = fields.get("description", "").strip()
|
||||
if not desc:
|
||||
errors.append("Missing required field: description")
|
||||
|
||||
if "license" not in fields or not fields["license"].strip():
|
||||
warnings.append("Missing recommended field: license")
|
||||
|
||||
if "metadata" not in fields or not fields["metadata"].strip():
|
||||
warnings.append("Missing recommended field: metadata")
|
||||
|
||||
for root, dirs, files in os.walk(skill_dir):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for fname in files:
|
||||
_, ext = os.path.splitext(fname)
|
||||
if ext not in SCAN_EXTENSIONS:
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
for line_no, sdesc, matched in scan_secrets(fpath):
|
||||
rel = os.path.relpath(fpath, skill_dir)
|
||||
errors.append(f"Potential secret in {rel}:{line_no} ({sdesc}): {matched}...")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate MiniMax Skills structure")
|
||||
parser.add_argument("--path", default="skills", help="Directory to scan (default: skills/)")
|
||||
args = parser.parse_args()
|
||||
|
||||
scan_path = os.path.abspath(args.path)
|
||||
|
||||
skill_dirs = find_skill_dirs(scan_path)
|
||||
if not skill_dirs:
|
||||
print("No skill directories found.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\nValidating {len(skill_dirs)} skill(s)...\n")
|
||||
|
||||
total_errors = 0
|
||||
total_warnings = 0
|
||||
|
||||
for sd in skill_dirs:
|
||||
rel = os.path.relpath(sd)
|
||||
errors, warnings = validate_skill(sd)
|
||||
|
||||
if errors:
|
||||
status = "FAIL"
|
||||
elif warnings:
|
||||
status = "WARN"
|
||||
else:
|
||||
status = "PASS"
|
||||
|
||||
print(f" [{status}] {rel}")
|
||||
for msg in errors:
|
||||
print(f" ERROR {msg}")
|
||||
for msg in warnings:
|
||||
print(f" WARN {msg}")
|
||||
|
||||
total_errors += len(errors)
|
||||
total_warnings += len(warnings)
|
||||
|
||||
print()
|
||||
if total_errors:
|
||||
print(f" {total_errors} error(s), {total_warnings} warning(s)")
|
||||
print(" Validation FAILED.\n")
|
||||
sys.exit(1)
|
||||
elif total_warnings:
|
||||
print(f" 0 errors, {total_warnings} warning(s)")
|
||||
print(" Validation PASSED.\n")
|
||||
else:
|
||||
print(" All checks passed.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user