mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-08-29 04:39:40 +03:00
fix: replace three hand-rolled YAML parsers with a shared PyYAML loader
index.json shipped 604 of 817 descriptions truncated to their first line.
The cause was the inline regex parser in update-index.yml: it reconstructed
multi-line descriptions only for the YAML block-scalar styles ('>' and '|')
and silently dropped continuation lines for every other style.
A census of the corpus explains the blast radius:
block scalar (description: >-) 43
single-quoted multiline 278
plain unquoted multiline 496
single-line 0
So 774 of 817 skills (94.7%) used a style the parser mishandled. Commit
d56fc0a7 had fixed only the 43 block-scalar files, and CONTRIBUTING.md
recommends that one working style, which is why it stayed hidden.
- add tools/skill_frontmatter.py, the single PyYAML-backed loader
- add tools/generate-index.py so generation is testable outside CI, with
a --check mode for use as a gate
- delete the hand-rolled parsers from validate-skill.py (98 lines) and
validate-agentskills.py, routing both through the shared loader
- implement the reserved-word check that agentskills-skill.schema.json
names validate-agentskills.py as the enforcement point for
Verified by a differential harness against yaml.safe_load ground truth:
index-vs-source mismatches 606 -> 0.
This commit is contained in:
+7
-106
@@ -10,6 +10,10 @@ import re
|
||||
import sys
|
||||
import glob
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skill_frontmatter import load_frontmatter, FrontmatterError
|
||||
|
||||
# Kept in sync with the CI workflow (.github/workflows/validate-skills.yml),
|
||||
# which now delegates to this script so there is a single source of truth.
|
||||
REQUIRED_FIELDS = ["name", "description", "domain", "subdomain", "tags",
|
||||
@@ -81,104 +85,6 @@ YELLOW = "\033[93m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def parse_frontmatter(text):
|
||||
"""Extract YAML frontmatter as a dict (simple stdlib-only parser).
|
||||
|
||||
Handles the common SKILL.md patterns:
|
||||
- key: scalar value
|
||||
- key: [inline, list]
|
||||
- key:\n - list\n - items
|
||||
- key: >- (folded scalar — content on following indented lines)
|
||||
|
||||
Edge case note: ``list_values`` is reset to ``[]`` whenever a new key
|
||||
with a scalar value is encountered, so a list from a prior block cannot
|
||||
leak into an unrelated key. The only remaining theoretical edge case is
|
||||
a key with *no* value that is immediately followed by non-list, non-empty
|
||||
lines that look like scalars — those lines are currently ignored (the key
|
||||
is treated as having no value). This is acceptable for well-formed SKILL.md
|
||||
files and matches the behaviour contributors expect.
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return None
|
||||
end = text.find("---", 3)
|
||||
if end == -1:
|
||||
return None
|
||||
block = text[3:end].strip()
|
||||
data = {}
|
||||
current_key = None
|
||||
list_values: list = []
|
||||
in_folded = False # True when we are collecting a YAML >- / > folded scalar
|
||||
folded_lines: list = []
|
||||
|
||||
for line in block.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
# Flush a completed folded scalar when we hit the next top-level key.
|
||||
if in_folded and stripped and not line.startswith(" ") and not line.startswith("\t"):
|
||||
if current_key and folded_lines:
|
||||
data[current_key] = " ".join(folded_lines)
|
||||
in_folded = False
|
||||
folded_lines = []
|
||||
current_key = None
|
||||
|
||||
if in_folded:
|
||||
if stripped:
|
||||
folded_lines.append(stripped)
|
||||
continue
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Handle list items (must come before key: value to avoid misparse).
|
||||
if stripped.startswith("- ") and current_key:
|
||||
list_values.append(stripped[2:].strip().strip('"').strip("'"))
|
||||
data[current_key] = list(list_values) # copy so future mutations don't leak
|
||||
continue
|
||||
|
||||
# Only TOP-LEVEL keys (column 0) define frontmatter fields. An indented
|
||||
# ``key: value`` line belongs to a nested structure (e.g. a framework
|
||||
# mapping object that has its own ``name:``/``id:``) and must NOT be
|
||||
# treated as a top-level field — otherwise a nested ``name:`` clobbers
|
||||
# the skill's real ``name``.
|
||||
if line[:1].isspace():
|
||||
continue
|
||||
|
||||
# Handle inline list: tags: [a, b, c]
|
||||
m = re.match(r"^(\w[\w_-]*):\s*\[(.+)\]\s*$", stripped)
|
||||
if m:
|
||||
current_key = m.group(1)
|
||||
items = [i.strip().strip('"').strip("'") for i in m.group(2).split(",")]
|
||||
data[current_key] = items
|
||||
list_values = list(items)
|
||||
continue
|
||||
|
||||
# Handle key: >- or key: > (folded scalar start)
|
||||
m = re.match(r"^(\w[\w_-]*):\s*>[-|]?\s*$", stripped)
|
||||
if m:
|
||||
current_key = m.group(1)
|
||||
list_values = []
|
||||
in_folded = True
|
||||
folded_lines = []
|
||||
continue
|
||||
|
||||
# Handle key: value (plain scalar)
|
||||
m = re.match(r'^(\w[\w_-]*):\s*(.*)$', stripped)
|
||||
if m:
|
||||
current_key = m.group(1)
|
||||
val = m.group(2).strip().strip('"').strip("'")
|
||||
list_values = [] # reset; new scalar key cannot inherit a prior list
|
||||
if val:
|
||||
data[current_key] = val
|
||||
# If val is empty the key is present but value-less (e.g. start of block list)
|
||||
continue
|
||||
|
||||
# Flush any trailing folded scalar.
|
||||
if in_folded and current_key and folded_lines:
|
||||
data[current_key] = " ".join(folded_lines)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def validate_skill(skill_dir):
|
||||
"""Validate a single skill directory. Returns list of error strings."""
|
||||
errors = []
|
||||
@@ -188,16 +94,11 @@ def validate_skill(skill_dir):
|
||||
return [f"SKILL.md not found in {skill_dir}"]
|
||||
|
||||
try:
|
||||
with open(skill_md, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
fm = load_frontmatter(skill_md)
|
||||
except FrontmatterError as e:
|
||||
return [str(e)]
|
||||
except IOError as e:
|
||||
return [f"Could not read SKILL.md: {e}"]
|
||||
except UnicodeDecodeError as e:
|
||||
return [f"Encoding error in SKILL.md (not valid UTF-8): {e}"]
|
||||
|
||||
fm = parse_frontmatter(content)
|
||||
if fm is None:
|
||||
return ["No valid YAML frontmatter found (must start with ---)"]
|
||||
|
||||
# Check required fields.
|
||||
for field in REQUIRED_FIELDS:
|
||||
|
||||
Reference in New Issue
Block a user