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
206 lines
6.4 KiB
Python
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())
|