#!/usr/bin/env python3 """ CODITECT Hook Validation Test Suite
Comprehensive tests to verify all 41 hooks have correct configuration, valid structure, and proper documentation.
Run: python3 scripts/tests/test_hooks.py python3 scripts/tests/test_hooks.py -v # Verbose python3 scripts/tests/test_hooks.py --hook pre-commit # Single hook
Author: CODITECT Team Version: 1.0.0 Created: 2025-12-22 """
import os import re import sys import json import argparse from pathlib import Path from typing import Dict, List, Tuple, Optional from dataclasses import dataclass
Shared Colors module (consolidates 36 duplicate definitions)
_script_dir = Path(file).parent.parent # tests/ -> scripts/ sys.path.insert(0, str(_script_dir / "core")) from colors import Colors
class TestResult: passed: bool message: str hook: str test_name: str
@dataclass class HookValidation: name: str path: str hook_type: str # 'markdown', 'shell', 'python' passed: bool tests: List[TestResult] errors: List[str] warnings: List[str]
class HookTestSuite: """Comprehensive test suite for CODITECT hooks"""
VALID_HOOK_EVENTS = [
'pre-commit', 'post-commit', 'pre-push', 'post-push',
'pre-session', 'post-session', 'pre-tool', 'post-tool',
'on-error', 'on-success', 'pre-agent', 'post-agent',
'user-prompt-submit', 'notification', 'stop'
]
def __init__(self, framework_root: Path, verbose: bool = False):
self.framework_root = framework_root
self.hooks_dir = framework_root / "hooks"
self.verbose = verbose
self.results: List[HookValidation] = []
def get_all_hooks(self) -> List[Tuple[Path, str]]:
"""Get all hook files with their types"""
hooks = []
if not self.hooks_dir.exists():
return hooks
# Markdown hook definitions
for f in sorted(self.hooks_dir.glob("*.md")):
if f.name != 'README.md':
hooks.append((f, 'markdown'))
# Shell hooks
for f in sorted(self.hooks_dir.glob("*.sh")):
hooks.append((f, 'shell'))
# Python hooks
for f in sorted(self.hooks_dir.glob("*.py")):
if f.name != '__init__.py':
hooks.append((f, 'python'))
return hooks
def parse_frontmatter(self, content: str) -> Tuple[Optional[Dict], str]:
"""Parse YAML frontmatter from markdown content"""
match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
if not match:
return None, content
frontmatter_text = match.group(1)
body = match.group(2)
frontmatter = {}
for line in frontmatter_text.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
frontmatter[key.strip()] = value.strip().strip('"\'')
return frontmatter, body
def test_file_exists(self, hook_path: Path, hook_name: str) -> TestResult:
"""Test that hook file exists"""
if hook_path.exists() and hook_path.is_file():
return TestResult(True, "File exists", hook_name, "file_exists")
return TestResult(False, "File does not exist", hook_name, "file_exists")
def test_has_documentation(self, content: str, hook_name: str, hook_type: str) -> TestResult:
"""Test that hook has documentation"""
if hook_type == 'markdown':
has_doc = len(content) > 100 and '#' in content
elif hook_type == 'shell':
has_doc = content.count('#') > 3 and len([l for l in content.split('\n') if l.startswith('#')]) > 2
elif hook_type == 'python':
has_doc = '"""' in content or "'''" in content
if has_doc:
return TestResult(True, "Has documentation", hook_name, "has_documentation")
return TestResult(False, "Missing documentation", hook_name, "has_documentation")
def test_valid_event_type(self, hook_path: Path, hook_name: str, hook_type: str) -> TestResult:
"""Test that hook name indicates a valid event type"""
# Extract event from hook name
name_lower = hook_name.lower().replace('_', '-')
# Check if any valid event is in the hook name
found_event = None
for event in self.VALID_HOOK_EVENTS:
if event in name_lower:
found_event = event
break
if found_event:
return TestResult(True, f"Valid event type: {found_event}", hook_name, "valid_event_type")
# Also check frontmatter for event specification
if hook_type == 'markdown':
content = hook_path.read_text(encoding='utf-8')
frontmatter, _ = self.parse_frontmatter(content)
if frontmatter and 'event' in frontmatter:
event = frontmatter['event']
if any(e in event.lower() for e in self.VALID_HOOK_EVENTS):
return TestResult(True, f"Event in frontmatter: {event}", hook_name, "valid_event_type")
# Not a failure, just a warning
return TestResult(True, "Event type not detected (check manually)", hook_name, "valid_event_type")
def test_shell_syntax(self, hook_path: Path, hook_name: str) -> TestResult:
"""Test shell hook syntax"""
import subprocess
try:
result = subprocess.run(
['bash', '-n', str(hook_path)],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
return TestResult(True, "Valid shell syntax", hook_name, "shell_syntax")
return TestResult(False, f"Syntax error: {result.stderr[:100]}", hook_name, "shell_syntax")
except Exception as e:
return TestResult(True, f"Could not check: {str(e)[:50]}", hook_name, "shell_syntax")
def test_python_syntax(self, hook_path: Path, hook_name: str) -> TestResult:
"""Test Python hook syntax"""
import ast
try:
content = hook_path.read_text(encoding='utf-8')
ast.parse(content)
return TestResult(True, "Valid Python syntax", hook_name, "python_syntax")
except SyntaxError as e:
return TestResult(False, f"Syntax error line {e.lineno}: {e.msg}", hook_name, "python_syntax")
except Exception as e:
return TestResult(False, f"Parse error: {str(e)}", hook_name, "python_syntax")
def test_has_shebang(self, hook_path: Path, hook_name: str, hook_type: str) -> TestResult:
"""Test that executable hooks have shebang"""
if hook_type == 'markdown':
return TestResult(True, "Shebang not required for markdown", hook_name, "has_shebang")
content = hook_path.read_text(encoding='utf-8')
first_line = content.split('\n')[0] if content else ''
if hook_type == 'shell':
if first_line.startswith('#!/'):
return TestResult(True, "Has shell shebang", hook_name, "has_shebang")
return TestResult(False, "Missing shebang", hook_name, "has_shebang")
if hook_type == 'python':
if first_line.startswith('#!/'):
return TestResult(True, "Has Python shebang", hook_name, "has_shebang")
return TestResult(False, "Missing shebang", hook_name, "has_shebang")
return TestResult(True, "Shebang check passed", hook_name, "has_shebang")
def test_no_dangerous_commands(self, hook_path: Path, hook_name: str) -> TestResult:
"""Test that hook doesn't contain dangerous commands"""
content = hook_path.read_text(encoding='utf-8')
dangerous = [
(r'\brm\s+-rf\s+/', 'rm -rf /'),
(r'\brm\s+-rf\s+\*', 'rm -rf *'),
(r'\bdd\s+if=.*of=/dev/', 'dd to device'),
(r':\s*\(\)\s*\{.*\|.*\}', 'fork bomb'),
(r'\beval\s+\$', 'eval with variable'),
]
found = []
for pattern, name in dangerous:
if re.search(pattern, content):
found.append(name)
if found:
return TestResult(
passed=False,
message=f"Dangerous patterns: {', '.join(found)}",
hook=hook_name,
test_name="no_dangerous_commands"
)
return TestResult(True, "No dangerous commands", hook_name, "no_dangerous_commands")
def test_minimum_content(self, hook_path: Path, hook_name: str) -> TestResult:
"""Test that hook has minimum content"""
content = hook_path.read_text(encoding='utf-8')
if len(content) < 20:
return TestResult(False, f"Too short ({len(content)} chars)", hook_name, "minimum_content")
return TestResult(True, f"Content OK ({len(content)} chars)", hook_name, "minimum_content")
def validate_hook(self, hook_path: Path, hook_type: str) -> HookValidation:
"""Run all validation tests on a hook"""
hook_name = hook_path.stem
tests = []
errors = []
warnings = []
tests.append(self.test_file_exists(hook_path, hook_name))
if hook_path.exists():
content = hook_path.read_text(encoding='utf-8')
tests.append(self.test_has_documentation(content, hook_name, hook_type))
tests.append(self.test_valid_event_type(hook_path, hook_name, hook_type))
tests.append(self.test_has_shebang(hook_path, hook_name, hook_type))
tests.append(self.test_no_dangerous_commands(hook_path, hook_name))
tests.append(self.test_minimum_content(hook_path, hook_name))
if hook_type == 'shell':
tests.append(self.test_shell_syntax(hook_path, hook_name))
elif hook_type == 'python':
tests.append(self.test_python_syntax(hook_path, hook_name))
for test in tests:
if not test.passed:
errors.append(f"{test.test_name}: {test.message}")
passed = all(t.passed for t in tests)
return HookValidation(
name=hook_name,
path=str(hook_path),
hook_type=hook_type,
passed=passed,
tests=tests,
errors=errors,
warnings=warnings
)
def run_all(self, specific_hook: Optional[str] = None) -> bool:
"""Run validation on all hooks"""
hooks = self.get_all_hooks()
if specific_hook:
hooks = [(h, t) for h, t in hooks if h.stem == specific_hook]
if not hooks:
print(f"{Colors.RED}Hook not found: {specific_hook}{Colors.RESET}")
return False
print(f"{Colors.BOLD}CODITECT Hook Validation Test Suite{Colors.RESET}")
print("=" * 50)
md_count = sum(1 for _, t in hooks if t == 'markdown')
sh_count = sum(1 for _, t in hooks if t == 'shell')
py_count = sum(1 for _, t in hooks if t == 'python')
print(f"\nValidating {len(hooks)} hooks ({md_count} MD, {sh_count} Shell, {py_count} Python)...\n")
passed_count = 0
failed_count = 0
total_tests = 0
for hook_path, hook_type in hooks:
validation = self.validate_hook(hook_path, hook_type)
self.results.append(validation)
total_tests += len(validation.tests)
if validation.passed:
passed_count += 1
if self.verbose:
print(f"{validation.name} ({hook_type}): {Colors.GREEN}PASS{Colors.RESET}")
else:
print(f"{validation.name}: {Colors.GREEN}PASS{Colors.RESET}")
else:
failed_count += 1
print(f"{validation.name}: {Colors.RED}FAIL{Colors.RESET}")
for error in validation.errors:
print(f" {Colors.RED}✗{Colors.RESET} {error}")
# Summary
print("\n" + "=" * 50)
print(f"{Colors.BOLD}Test Summary{Colors.RESET}")
print("=" * 50)
print(f"\nHooks: {passed_count}/{len(hooks)} passed")
print(f"Tests: {sum(1 for r in self.results for t in r.tests if t.passed)}/{total_tests} passed")
if failed_count == 0:
print(f"\n{Colors.GREEN}{Colors.BOLD}All hooks passed validation!{Colors.RESET}")
return True
else:
print(f"\n{Colors.RED}{Colors.BOLD}{failed_count} hook(s) failed validation{Colors.RESET}")
return False
def get_framework_root() -> Path: """Get framework root directory""" script_path = Path(file).resolve() return script_path.parent.parent.parent
def main(): parser = argparse.ArgumentParser(description='CODITECT Hook Validation Test Suite') parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('--hook', type=str, help='Test specific hook') parser.add_argument('--json', action='store_true', help='Output results as JSON') args = parser.parse_args()
framework_root = get_framework_root()
suite = HookTestSuite(framework_root, verbose=args.verbose)
success = suite.run_all(specific_hook=args.hook)
if args.json:
results = {
'total_hooks': len(suite.results),
'passed': sum(1 for r in suite.results if r.passed),
'failed': sum(1 for r in suite.results if not r.passed),
'hooks': [
{
'name': r.name,
'type': r.hook_type,
'passed': r.passed,
'errors': r.errors
}
for r in suite.results
]
}
print(json.dumps(results, indent=2))
sys.exit(0 if success else 1)
if name == 'main': main()