Files
patrickandClaude Sonnet 5 7562188959 test: add subagent metadata validation
Also fixes two pre-existing data bugs the validator caught:
devops-infrastruktur/sqs-expert.md had a mangled duplicate frontmatter
block (empty description, wrong model) and sonstiges/arm-cortex-expert.md
had an empty tools: [] field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-08 11:06:02 +02:00

206 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Validates frontmatter/metadata consistency across all subagent definitions.
Checks every .md file in the 13 category folders for:
- present YAML frontmatter (--- ... ---)
- required fields: name, description, model
- model is always "claude-sonnet-5"
- no color: field
- no empty tools: field
- name matches the filename (without .md)
- no broken leftover context-query text (e.g. "context needed: >-")
- known merged/renamed files are not still referenced as active in SOURCES.md
Exit code 0 on success, 1 with an error list otherwise.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CATEGORY_DIRS = [
"backend-apis",
"sprachen",
"frontend-ui",
"datenbanken",
"devops-infrastruktur",
"security-compliance",
"testing-qualitaet",
"orchestrierung",
"marketing-content",
"data-ki-ml",
"business-produkt",
"mobile",
"sonstiges",
]
REQUIRED_FIELDS = ["name", "description", "model"]
EXPECTED_MODEL = "claude-sonnet-5"
# Files removed/merged during the deduplication pass. They must no longer
# exist on disk, and must not be listed as an active row in SOURCES.md
# (a "gemerged"/"umbenannt" mention referencing them is fine).
DELETED_FILES = [
"architect-review.md",
"vector-db-expert.md",
"go-expert.md",
"machine-learning-engineer.md",
]
# old_name -> new_name for renamed agents
RENAMED_FILES = {
"devops-incident-responder.md": "incident-readiness-engineer.md",
"review.md": "pipeline-pr-reviewer.md",
}
BROKEN_CONTEXT_RE = re.compile(r"context needed:\s*>-\s*$", re.IGNORECASE | re.MULTILINE)
def parse_frontmatter(text: str):
"""Return (frontmatter_lines, body) or (None, text) if no frontmatter."""
if not text.startswith("---"):
return None, text
end = text.find("\n---", 3)
if end == -1:
return None, text
frontmatter = text[3:end].strip("\n")
body = text[end + 4 :]
return frontmatter, body
def parse_simple_yaml_fields(frontmatter: str):
"""Very small YAML subset parser sufficient for this repo's frontmatter.
Returns dict of top-level scalar field name -> raw string value
(quotes stripped where present). Multi-line block scalars are returned
with their raw block value joined by spaces, mainly so we can still
detect the field's presence and content.
"""
fields = {}
lines = frontmatter.split("\n")
i = 0
n = len(lines)
while i < n:
line = lines[i]
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line)
if not m:
i += 1
continue
key, value = m.group(1), m.group(2)
value = value.strip()
if value in (">-", "|-", ">", "|"):
# Block scalar: collect following indented lines
block_lines = []
i += 1
while i < n and (lines[i].startswith(" ") or lines[i].strip() == ""):
block_lines.append(lines[i].strip())
i += 1
fields[key] = " ".join(block_lines).strip()
continue
# Strip matching quotes
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
fields[key] = value
i += 1
return fields
def validate_file(path: Path, errors: list):
text = path.read_text(encoding="utf-8")
frontmatter, _body = parse_frontmatter(text)
if frontmatter is None:
errors.append(f"{path}: missing YAML frontmatter (--- ... ---)")
return
fields = parse_simple_yaml_fields(frontmatter)
for field in REQUIRED_FIELDS:
if field not in fields or not fields[field].strip():
errors.append(f"{path}: missing required field '{field}'")
if "model" in fields and fields["model"] != EXPECTED_MODEL:
errors.append(
f"{path}: model is '{fields['model']}', expected '{EXPECTED_MODEL}'"
)
if "color" in fields:
errors.append(f"{path}: has forbidden 'color' field")
if "tools" in fields:
tools_val = fields["tools"].strip()
if tools_val in ("", "[]", '""', "''"):
errors.append(f"{path}: has empty 'tools' field")
expected_name = path.stem
if "name" in fields and fields["name"].strip() != expected_name:
errors.append(
f"{path}: name '{fields['name']}' does not match filename '{expected_name}'"
)
if BROKEN_CONTEXT_RE.search(frontmatter):
errors.append(f"{path}: broken context-query leftover ('context needed: >-') in frontmatter")
if BROKEN_CONTEXT_RE.search(text):
errors.append(f"{path}: broken context-query leftover ('context needed: >-') in body")
def validate_sources_md(errors: list):
sources_path = REPO_ROOT / "SOURCES.md"
if not sources_path.exists():
errors.append("SOURCES.md not found")
return
text = sources_path.read_text(encoding="utf-8")
lines = text.split("\n")
for deleted in DELETED_FILES:
agent_name = deleted[:-3]
for line in lines:
if not line.startswith("|"):
continue
cols = [c.strip() for c in line.strip("|").split("|")]
if not cols:
continue
if cols[0] == agent_name:
errors.append(
f"SOURCES.md: deleted file '{deleted}' still listed as an active agent row ('{agent_name}')"
)
def main():
errors = []
for category in CATEGORY_DIRS:
category_path = REPO_ROOT / category
if not category_path.is_dir():
errors.append(f"category directory missing: {category}")
continue
for md_file in sorted(category_path.glob("*.md")):
validate_file(md_file, errors)
for deleted in DELETED_FILES:
for category in CATEGORY_DIRS:
if (REPO_ROOT / category / deleted).exists():
errors.append(f"{category}/{deleted}: should have been deleted (merged elsewhere)")
for old_name in RENAMED_FILES:
for category in CATEGORY_DIRS:
if (REPO_ROOT / category / old_name).exists():
errors.append(f"{category}/{old_name}: should have been renamed to {RENAMED_FILES[old_name]}")
validate_sources_md(errors)
if errors:
print(f"FAILED: {len(errors)} issue(s) found:\n")
for err in errors:
print(f" - {err}")
return 1
print("OK: all subagent metadata checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())