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:
Mahipal
2026-08-23 17:15:12 +02:00
parent f76261573a
commit 796d96c413
5 changed files with 272 additions and 219 deletions
+7 -106
View File
@@ -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: