#!/usr/bin/env python3 """ CODITECT Skill Frontmatter Fixer
Auto-fixes the two highest-impact QA failures in skills:
- name: must match directory name (lowercase-hyphenated)
- description: must exist (non-empty, ≤1024 chars, third-person)
Targets the 282 F-grade skills identified in QA remediation plan.
Usage: python3 scripts/qa/fix-skill-frontmatter.py [--dry-run] [--verbose] [--path skills/]
ADR-161: Component Quality Assurance Framework Track: E.14 (QA Remediation) """
import os import sys import re import argparse from pathlib import Path
try: import yaml except ImportError: print("ERROR: PyYAML required. Install with: pip3 install pyyaml") sys.exit(1)
CODITECT_CORE = Path(file).resolve().parents[2] SKILLS_DIR = CODITECT_CORE / "skills"
FRONTMATTER_RE = re.compile(r'^(---\s*\n)(.?)(\n---\s\n)', re.DOTALL)
def extract_description(fm, body): """Extract a valid description from available content.
Priority:
1. Existing description field (if valid)
2. summary field
3. First non-heading paragraph from body
"""
# Check existing description
desc = str(fm.get('description', '')).strip()
if desc and 0 < len(desc) <= 1024:
return fix_third_person(desc)
# Try summary field
summary = str(fm.get('summary', '')).strip()
if summary and len(summary) > 10:
return fix_third_person(truncate(summary, 1024))
# Extract from body: first non-heading, non-empty paragraph
for line in body.split('\n'):
line = line.strip()
if not line or line.startswith('#') or line.startswith('---'):
continue
if line.startswith('|') or line.startswith('```'):
continue
if len(line) >= 20:
return fix_third_person(truncate(line, 1024))
return None
def fix_third_person(desc): """Ensure description doesn't start with 'you'.""" if not desc: return desc if desc.lower().startswith('you '): # Convert "You can use..." → "Enables..." or just strip "You" desc = desc[4:] if desc and desc[0].islower(): desc = desc[0].upper() + desc[1:] return desc
def truncate(text, max_len): """Truncate text to max_len, breaking at last space.""" if len(text) <= max_len: return text truncated = text[:max_len] last_space = truncated.rfind(' ') if last_space > max_len // 2: return truncated[:last_space] return truncated
def fix_yaml_field(fm_text, field, new_value): """Replace or add a YAML field in frontmatter text.
Uses regex to preserve formatting of other fields.
"""
# Escape single quotes in value for YAML
safe_value = new_value.replace("'", "''")
# Check if field exists
field_re = re.compile(rf'^({field}:\s*)(.*)$', re.MULTILINE)
match = field_re.search(fm_text)
if match:
# Replace existing field value
return field_re.sub(rf'\g<1>{quote_yaml(new_value)}', fm_text, count=1)
else:
# Add field after the first line (after 'name:' if setting description,
# or as first field if setting name)
if field == 'description':
# Add after name: line if it exists
name_re = re.compile(r'^(name:.*$)', re.MULTILINE)
name_match = name_re.search(fm_text)
if name_match:
insert_pos = name_match.end()
return fm_text[:insert_pos] + f'\n{field}: {quote_yaml(new_value)}' + fm_text[insert_pos:]
# Add as second line (after first field)
first_newline = fm_text.find('\n')
if first_newline >= 0:
return fm_text[:first_newline + 1] + f'{field}: {quote_yaml(new_value)}\n' + fm_text[first_newline + 1:]
return f'{field}: {quote_yaml(new_value)}\n' + fm_text
def quote_yaml(value): """Quote a YAML value if it contains special characters.""" if not value: return "''" # Need quoting if contains: colon, hash, brackets, quotes, or starts with special chars needs_quotes = any(c in value for c in ':{}[]#&*!|>'"@`') or value.startswith(('- ', '? ')) if needs_quotes: # Use single quotes, escaping internal single quotes escaped = value.replace("'", "''") return f"'{escaped}'" return value
def fix_skill(skill_dir, dry_run=False, verbose=False): """Fix frontmatter in a single skill.
Returns:
dict with changes made, or None if no changes needed
"""
skill_name = skill_dir.name
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
return None
with open(skill_file, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
fm_match = FRONTMATTER_RE.match(content)
if not fm_match:
return {'name': skill_name, 'error': 'No frontmatter found'}
fm_start = fm_match.group(1) # "---\n"
fm_text = fm_match.group(2) # YAML content
fm_end = fm_match.group(3) # "\n---\n"
body = content[fm_match.end():]
try:
fm = yaml.safe_load(fm_text) or {}
except yaml.YAMLError:
return {'name': skill_name, 'error': 'Invalid YAML'}
changes = []
new_fm_text = fm_text
# Fix 1: name field must match directory name
current_name = str(fm.get('name', ''))
if current_name != skill_name:
new_fm_text = fix_yaml_field(new_fm_text, 'name', skill_name)
changes.append(f'name: "{current_name}" → "{skill_name}"')
# Fix 2: description must exist and be valid
current_desc = str(fm.get('description', '')).strip()
needs_desc = not current_desc or len(current_desc) > 1024
desc_starts_with_you = current_desc.lower().startswith('you')
if needs_desc or desc_starts_with_you:
# Re-parse the potentially modified frontmatter to get updated fm
try:
updated_fm = yaml.safe_load(new_fm_text) or {}
except yaml.YAMLError:
updated_fm = fm
new_desc = extract_description(fm, body)
if new_desc:
new_fm_text = fix_yaml_field(new_fm_text, 'description', new_desc)
if needs_desc:
changes.append(f'description: added ({len(new_desc)} chars)')
else:
changes.append(f'description: fixed third-person')
if not changes:
return None
if not dry_run:
new_content = fm_start + new_fm_text + fm_end + body
with open(skill_file, 'w', encoding='utf-8') as f:
f.write(new_content)
return {
'name': skill_name,
'changes': changes,
'dry_run': dry_run,
}
def main(): parser = argparse.ArgumentParser( description='Fix CODITECT skill YAML frontmatter (name + description)') parser.add_argument('--path', default=str(SKILLS_DIR), help='Skills directory (default: skills/)') parser.add_argument('--dry-run', '-n', action='store_true', help='Preview changes without writing') parser.add_argument('--verbose', '-v', action='store_true', help='Show details for each skill') args = parser.parse_args()
target = Path(args.path)
skill_dirs = sorted([
d for d in target.iterdir()
if d.is_dir() and (d / 'SKILL.md').exists()
])
fixed = 0
skipped = 0
errors = 0
total = len(skill_dirs)
print(f"{'[DRY RUN] ' if args.dry_run else ''}Scanning {total} skills...")
print(f"{'=' * 60}")
for d in skill_dirs:
result = fix_skill(d, dry_run=args.dry_run, verbose=args.verbose)
if result is None:
skipped += 1
continue
if 'error' in result:
errors += 1
if args.verbose:
print(f" ERROR {result['name']}: {result['error']}")
continue
fixed += 1
if args.verbose or args.dry_run:
prefix = "[DRY] " if args.dry_run else "FIXED"
print(f" {prefix} {result['name']}:")
for change in result['changes']:
print(f" {change}")
print(f"\n{'=' * 60}")
print(f"{'[DRY RUN] ' if args.dry_run else ''}Results:")
print(f" Total skills: {total}")
print(f" Fixed: {fixed}")
print(f" Already OK: {skipped}")
print(f" Errors: {errors}")
if args.dry_run and fixed > 0:
print(f"\nRun without --dry-run to apply {fixed} fixes.")
if name == 'main': main()