#!/usr/bin/env python3 """ CODITECT Script QA Grader
Grades scripts against CODITECT-STANDARD-SCRIPTS.md criteria.
Weights: Structure (20%), CLI Interface (20%), Security (20%), Error Handling (20%), Documentation (20%)
Usage: python3 scripts/qa/grade-scripts.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, grade_from_score, compute_weighted_score, aggregate_results, output_results )
CODITECT_CORE = Path(file).resolve().parents[2] SCRIPTS_DIR = CODITECT_CORE / "scripts"
Directories to skip (not actual scripts)
SKIP_DIRS = {'pycache', 'agents', 'qa', 'core', 'moe_classifier', 'context-storage'}
def grade_script(filepath): """Grade a single script file.""" filename = os.path.basename(filepath) script_name = filename.rsplit('.', 1)[0] ext = filepath.rsplit('.', 1)[-1] if '.' in filepath else ''
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
is_python = ext == 'py'
is_shell = ext == 'sh'
line_count = len(content.split('\n'))
scores = {}
# A. STRUCTURE (20%)
if is_python:
scores['A1_shebang'] = 1 if content.startswith('#!/usr/bin/env python3') or content.startswith('#!/usr/bin/python3') else 0
elif is_shell:
scores['A1_shebang'] = 1 if content.startswith('#!/bin/bash') or content.startswith('#!/usr/bin/env bash') or content.startswith('#!/bin/sh') else 0
else:
scores['A1_shebang'] = 0
# Check file permissions
scores['A2_executable'] = 1 if os.access(filepath, os.X_OK) or is_python else 0
if is_python:
scores['A3_docstring'] = 1 if re.search(r'^""".*?"""', content, re.DOTALL | re.MULTILINE) else 0
elif is_shell:
scores['A3_docstring'] = 1 if re.search(r'^#\s+\w+.*\n#\s+\w+', content, re.MULTILINE) else 0
else:
scores['A3_docstring'] = 0
scores['A4_function_org'] = 1 if re.search(r'(def\s+\w+|function\s+\w+|\w+\(\)\s*\{)', content) else 0
# B. CLI INTERFACE (20%)
if is_python:
scores['B1_argparse'] = 1 if re.search(r'(argparse|ArgumentParser|click\.|typer\.)', content) else 0
scores['B2_help_flag'] = 1 if re.search(r'(--help|add_argument.*help=|\.add_help)', content) else 0
elif is_shell:
scores['B1_argparse'] = 1 if re.search(r'(getopts|getopt|while.*\$1|case.*\$)', content) else 0
scores['B2_help_flag'] = 1 if re.search(r'(-h\)|--help\)|usage\(\)|show_help)', content) else 0
else:
scores['B1_argparse'] = 0
scores['B2_help_flag'] = 0
scores['B3_verbose_flag'] = 1 if re.search(r'(--verbose|-v\b|verbose)', content) else 0
scores['B4_dry_run'] = 1 if re.search(r'(--dry.?run|dry_run|DRY_RUN)', content) else 0
# C. SECURITY (20%)
scores['C1_input_validation'] = 1 if re.search(r'(validate|sanitize|check_path|os\.path\.exists|Path\(.*\)\.exists|isinstance)', content) else 0
has_shell_true = bool(re.search(r'shell\s*=\s*True', content))
has_eval = bool(re.search(r'\beval\s*\(', content))
scores['C2_no_injection'] = 0 if (has_shell_true or has_eval) else 1
scores['C3_no_hardcoded'] = 0 if re.search(r'(password\s*=\s*["\'][^"\']+|api_key\s*=\s*["\'][^"\']+|/Users/\w+)', content) else 1
# D. ERROR HANDLING (20%)
if is_python:
scores['D1_specific_except'] = 1 if re.search(r'except\s+(\w+Error|\w+Exception|OSError|IOError|ValueError|KeyError)', content) else 0
bare_except = bool(re.search(r'except\s*:', content))
specific_except = bool(re.search(r'except\s+\w+', content))
scores['D1_specific_except'] = 1 if specific_except and not bare_except else (0 if bare_except and not specific_except else 1)
else:
scores['D1_specific_except'] = 1 if re.search(r'(trap|set\s+-e|error_exit)', content) else 0
scores['D2_cleanup'] = 1 if re.search(r'(finally:|atexit|tempfile|cleanup|trap.*EXIT)', content) else 0
scores['D3_logging'] = 1 if re.search(r'(logging\.|logger\.|print\(f.*error|print\(.*Error|echo.*ERROR)', content, re.IGNORECASE) else 0
scores['D4_exit_code'] = 1 if re.search(r'(sys\.exit\s*\(\s*[1-9]|exit\s+[1-9]|return\s+1)', content) else 0
# E. DOCUMENTATION (20%)
if is_python:
scores['E1_usage_docstring'] = 1 if re.search(r'""".*usage.*"""', content, re.DOTALL | re.IGNORECASE) or re.search(r'Usage:', content) else 0
else:
scores['E1_usage_docstring'] = 1 if re.search(r'(Usage:|USAGE|usage\(\))', content) else 0
comments = re.findall(r'#\s+\w+', content)
scores['E2_inline_comments'] = 1 if len(comments) >= 5 else 0
scores['E3_main_guard'] = 1 if re.search(r"if\s+__name__\s*==\s*['\"]__main__['\"]", content) or is_shell else 0
categories = [
('A_structure', 20, ['A1_shebang', 'A2_executable', 'A3_docstring', 'A4_function_org']),
('B_cli_interface', 20, ['B1_argparse', 'B2_help_flag', 'B3_verbose_flag', 'B4_dry_run']),
('C_security', 20, ['C1_input_validation', 'C2_no_injection', 'C3_no_hardcoded']),
('D_error_handling', 20, ['D1_specific_except', 'D2_cleanup', 'D3_logging', 'D4_exit_code']),
('E_documentation', 20, ['E1_usage_docstring', 'E2_inline_comments', 'E3_main_guard']),
]
total_base, category_scores = compute_weighted_score(scores, categories)
return {
'name': script_name,
'scores': scores,
'category_scores': category_scores,
'total_base': total_base,
'grade': grade_from_score(total_base),
'word_count': count_words(content),
'line_count': line_count,
'file_type': ext,
'track': 'N/A',
}
def main(): parser = argparse.ArgumentParser(description='Grade CODITECT scripts') parser.add_argument('path', nargs='?', default=str(SCRIPTS_DIR), help='Script file or directory') 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.is_file():
script_files = [target]
else:
script_files = sorted([
f for f in target.rglob('*')
if f.is_file() and f.suffix in {'.py', '.sh'}
and f.name != '__init__.py'
and not any(skip in f.relative_to(target).parts for skip in SKIP_DIRS)
])
results = []
errors = []
for filepath in script_files:
try:
results.append(grade_script(str(filepath)))
except Exception as e:
errors.append({'file': filepath.name, 'error': str(e)})
data = aggregate_results(results, 'scripts')
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()