Skip to main content

#!/usr/bin/env python3 """ CODITECT Skill Frontmatter Fixer

Auto-fixes the two highest-impact QA failures in skills:

  1. name: must match directory name (lowercase-hyphenated)
  2. 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()