Skip to main content

#!/usr/bin/env python3 """ CODITECT Skill QA Grader

Grades skills against CODITECT-STANDARD-SKILLS.md criteria.

Weights: YAML Frontmatter (40%), Progressive Disclosure (25%), Instruction Quality (25%), File Structure (10%)

Usage: python3 scripts/qa/grade-skills.py [path] [--json output.json] [--verbose]

ADR-161: Component Quality Assurance Framework """

import os import sys import re import json import argparse from pathlib import Path

sys.path.insert(0, os.path.dirname(file)) from qa_common import ( parse_frontmatter, count_words, content_quality_score, grade_from_score, compute_weighted_score, aggregate_results, output_results, VALID_TOOLS )

CODITECT_CORE = Path(file).resolve().parents[2] SKILLS_DIR = CODITECT_CORE / "skills"

def grade_skill(skill_dir): """Grade a single skill directory.""" 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, body = parse_frontmatter(content)
body_lower = body.lower()
word_count = count_words(body)
line_count = len(body.split('\n'))
scores = {}

# A. YAML FRONTMATTER (40%)
fm_name = str(fm.get('name', ''))
scores['A1_name_matches_dir'] = 1 if fm_name == skill_name else 0
scores['A2_name_format'] = 1 if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', fm_name) and len(fm_name) <= 64 else 0
desc = str(fm.get('description', ''))
scores['A3_description_valid'] = 1 if desc and 0 < len(desc) <= 1024 else 0
scores['A4_no_forbidden'] = 1 if not re.search(r'(anthropic|claude)', fm_name.lower()) else 0
scores['A5_third_person'] = 1 if desc and not desc.lower().startswith('you') else 0

# B. PROGRESSIVE DISCLOSURE (25%)
scores['B1_under_500_lines'] = 1 if line_count <= 500 else 0
token_estimate = word_count * 1.3
scores['B2_under_5000_tokens'] = 1 if token_estimate <= 5000 else 0
# Level 3 resources in separate files if body is large
has_extra_files = any(f.suffix == '.md' and f.name != 'SKILL.md' for f in skill_dir.iterdir() if f.is_file())
scores['B3_level3_resources'] = 1 if line_count <= 300 or has_extra_files else 0
scores['B4_toc_if_long'] = 1 if line_count <= 100 or re.search(r'##\s*(table\s+of\s+contents|contents|toc)', body_lower) else 0

# C. INSTRUCTION QUALITY (25%)
scores['C1_purpose_section'] = 1 if re.search(r'##\s*(purpose|when\s+to\s+use|overview)', body_lower) else 0
scores['C2_step_instructions'] = 1 if re.search(r'^\d+\.', body, re.MULTILINE) or re.search(r'##\s*steps', body_lower) else 0
code_blocks = re.findall(r'```', body)
scores['C3_code_examples'] = 1 if len(code_blocks) >= 4 else 0 # At least 2 examples (open+close each)
scores['C4_integration'] = 1 if re.search(r'##\s*(integration|related)', body_lower) else 0
scores['C5_principles'] = 1 if re.search(r'##\s*(principles|anti.?patterns|best\s+practices)', body_lower) else 0

# D. FILE STRUCTURE (10%)
scores['D1_dir_matches_name'] = 1 if skill_dir.name == fm_name or not fm_name else 0
scores['D2_skill_md_exists'] = 1 # Already checked above
scores['D3_resources_organized'] = 1 if not has_extra_files or all(f.suffix in {'.md', '.py', '.json', '.yaml', '.yml'} for f in skill_dir.iterdir() if f.is_file()) else 0

categories = [
('A_frontmatter', 40, ['A1_name_matches_dir', 'A2_name_format', 'A3_description_valid', 'A4_no_forbidden', 'A5_third_person']),
('B_progressive_disclosure', 25, ['B1_under_500_lines', 'B2_under_5000_tokens', 'B3_level3_resources', 'B4_toc_if_long']),
('C_instruction_quality', 25, ['C1_purpose_section', 'C2_step_instructions', 'C3_code_examples', 'C4_integration', 'C5_principles']),
('D_file_structure', 10, ['D1_dir_matches_name', 'D2_skill_md_exists', 'D3_resources_organized']),
]
total_base, category_scores = compute_weighted_score(scores, categories)

return {
'name': skill_name,
'scores': scores,
'category_scores': category_scores,
'total_base': total_base,
'grade': grade_from_score(total_base),
'word_count': word_count,
'line_count': line_count,
'track': fm.get('track', 'N/A'),
}

def main(): parser = argparse.ArgumentParser(description='Grade CODITECT skills') parser.add_argument('path', nargs='?', default=str(SKILLS_DIR), help='Skill dir or parent') parser.add_argument('--json', dest='json_output', help='Output JSON to file') parser.add_argument('--verbose', action='store_true') args = parser.parse_args()

target = Path(args.path)
if (target / 'SKILL.md').exists():
skill_dirs = [target]
else:
skill_dirs = sorted([d for d in target.iterdir() if d.is_dir() and (d / 'SKILL.md').exists()])

results = []
errors = []
for d in skill_dirs:
try:
r = grade_skill(d)
if r:
results.append(r)
except Exception as e:
errors.append({'file': d.name, 'error': str(e)})

data = aggregate_results(results, 'skills')
data['errors'] = errors

if args.json_output:
output_results(data, args.json_output, 'json')
output_results(data, format='summary')

if args.verbose:
sorted_results = sorted(results, key=lambda x: x['total_base'], reverse=True)
print(f"\nTOP 10:")
for r in sorted_results[:10]:
print(f" {r['grade']} {r['total_base']:5.1f}% | {r['name']}")
print(f"\nBOTTOM 10:")
for r in sorted_results[-10:]:
print(f" {r['grade']} {r['total_base']:5.1f}% | {r['name']}")

if name == 'main': main()