Initial commit
This commit is contained in:
280
skills/tdd-methodology-expert/scripts/check_tdd_compliance.py
Normal file
280
skills/tdd-methodology-expert/scripts/check_tdd_compliance.py
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TDD Compliance Checker
|
||||
|
||||
Analyzes code to detect if Test-Driven Development was followed.
|
||||
Identifies code smells and patterns that indicate tests-after-code.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class TDDComplianceChecker:
|
||||
"""Checks code for TDD compliance indicators."""
|
||||
|
||||
# Code smell patterns that suggest tests-after-code
|
||||
CODE_SMELLS = {
|
||||
'nested_conditionals': r'if\s+.*:\s*\n\s+if\s+.*:|if\s+.*:\s*\n\s+elif\s+',
|
||||
'long_methods': None, # Checked by line count
|
||||
'complex_conditions': r'if\s+.*\s+(and|or)\s+.*\s+(and|or)\s+',
|
||||
'multiple_responsibilities': None, # Checked by method analysis
|
||||
'missing_abstractions': r'if\s+isinstance\(',
|
||||
'god_class': None, # Checked by class analysis
|
||||
}
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
self.issues = []
|
||||
self.metrics = {
|
||||
'files_analyzed': 0,
|
||||
'test_files_found': 0,
|
||||
'code_smells': 0,
|
||||
'tdd_score': 0.0
|
||||
}
|
||||
|
||||
def analyze(self) -> Dict:
|
||||
"""Run full TDD compliance analysis."""
|
||||
if self.path.is_file():
|
||||
self._analyze_file(self.path)
|
||||
else:
|
||||
self._analyze_directory(self.path)
|
||||
|
||||
self._calculate_tdd_score()
|
||||
return {
|
||||
'issues': self.issues,
|
||||
'metrics': self.metrics,
|
||||
'compliance': self._get_compliance_level()
|
||||
}
|
||||
|
||||
def _analyze_directory(self, directory: Path):
|
||||
"""Recursively analyze all source files in directory."""
|
||||
# Common source file extensions
|
||||
extensions = {'.py', '.js', '.ts', '.java', '.go', '.rb', '.php', '.c', '.cpp', '.cs'}
|
||||
|
||||
for file_path in directory.rglob('*'):
|
||||
if file_path.suffix in extensions and file_path.is_file():
|
||||
# Skip test files in analysis (we'll check they exist separately)
|
||||
if not self._is_test_file(file_path):
|
||||
self._analyze_file(file_path)
|
||||
|
||||
def _analyze_file(self, file_path: Path):
|
||||
"""Analyze a single source file for TDD compliance."""
|
||||
self.metrics['files_analyzed'] += 1
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
|
||||
# Check for code smells
|
||||
self._check_nested_conditionals(file_path, content)
|
||||
self._check_long_methods(file_path, lines)
|
||||
self._check_complex_conditions(file_path, content)
|
||||
self._check_missing_abstractions(file_path, content)
|
||||
|
||||
# Check if corresponding test file exists
|
||||
self._check_test_coverage(file_path)
|
||||
|
||||
except Exception as e:
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'type': 'error',
|
||||
'message': f'Failed to analyze file: {str(e)}'
|
||||
})
|
||||
|
||||
def _check_nested_conditionals(self, file_path: Path, content: str):
|
||||
"""Detect deeply nested conditional statements."""
|
||||
pattern = self.CODE_SMELLS['nested_conditionals']
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'line': line_num,
|
||||
'type': 'code_smell',
|
||||
'severity': 'high',
|
||||
'smell': 'nested_conditionals',
|
||||
'message': 'Nested conditional statements detected. TDD typically produces flatter, more testable code structures.'
|
||||
})
|
||||
self.metrics['code_smells'] += 1
|
||||
|
||||
def _check_long_methods(self, file_path: Path, lines: List[str]):
|
||||
"""Detect methods/functions that are too long."""
|
||||
# Simple heuristic: methods longer than 20 lines
|
||||
in_method = False
|
||||
method_start = 0
|
||||
method_name = ''
|
||||
indent_level = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
|
||||
# Detect method/function definitions (language-agnostic patterns)
|
||||
if any(keyword in stripped for keyword in ['def ', 'function ', 'func ', 'public ', 'private ', 'protected ']):
|
||||
if '{' in stripped or ':' in stripped:
|
||||
in_method = True
|
||||
method_start = i + 1
|
||||
method_name = stripped.split('(')[0].split()[-1]
|
||||
indent_level = len(line) - len(stripped)
|
||||
|
||||
# Check if method ended
|
||||
elif in_method:
|
||||
current_indent = len(line) - len(line.lstrip())
|
||||
if stripped and current_indent <= indent_level and stripped not in ['}', 'end']:
|
||||
method_length = i - method_start
|
||||
if method_length > 20:
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'line': method_start,
|
||||
'type': 'code_smell',
|
||||
'severity': 'medium',
|
||||
'smell': 'long_method',
|
||||
'message': f'Method "{method_name}" is {method_length} lines long. TDD encourages smaller, focused methods.'
|
||||
})
|
||||
self.metrics['code_smells'] += 1
|
||||
in_method = False
|
||||
|
||||
def _check_complex_conditions(self, file_path: Path, content: str):
|
||||
"""Detect overly complex conditional expressions."""
|
||||
pattern = self.CODE_SMELLS['complex_conditions']
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'line': line_num,
|
||||
'type': 'code_smell',
|
||||
'severity': 'medium',
|
||||
'smell': 'complex_conditions',
|
||||
'message': 'Complex boolean conditions detected. TDD promotes simpler, more testable conditions.'
|
||||
})
|
||||
self.metrics['code_smells'] += 1
|
||||
|
||||
def _check_missing_abstractions(self, file_path: Path, content: str):
|
||||
"""Detect type checking that suggests missing abstractions."""
|
||||
pattern = self.CODE_SMELLS['missing_abstractions']
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'line': line_num,
|
||||
'type': 'code_smell',
|
||||
'severity': 'medium',
|
||||
'smell': 'missing_abstractions',
|
||||
'message': 'Type checking detected. TDD encourages polymorphism over type checking.'
|
||||
})
|
||||
self.metrics['code_smells'] += 1
|
||||
|
||||
def _check_test_coverage(self, file_path: Path):
|
||||
"""Check if a corresponding test file exists."""
|
||||
test_file = self._find_test_file(file_path)
|
||||
|
||||
if test_file and test_file.exists():
|
||||
self.metrics['test_files_found'] += 1
|
||||
else:
|
||||
self.issues.append({
|
||||
'file': str(file_path),
|
||||
'type': 'missing_test',
|
||||
'severity': 'critical',
|
||||
'message': f'No corresponding test file found. Expected: {test_file}'
|
||||
})
|
||||
|
||||
def _find_test_file(self, source_file: Path) -> Path:
|
||||
"""Find the expected test file location for a source file."""
|
||||
# Common test file patterns
|
||||
test_patterns = [
|
||||
lambda p: p.parent / f'test_{p.name}',
|
||||
lambda p: p.parent / f'{p.stem}_test{p.suffix}',
|
||||
lambda p: p.parent / 'tests' / f'test_{p.name}',
|
||||
lambda p: p.parent.parent / 'tests' / p.parent.name / f'test_{p.name}',
|
||||
lambda p: p.parent.parent / 'test' / p.parent.name / f'test_{p.name}',
|
||||
]
|
||||
|
||||
for pattern in test_patterns:
|
||||
test_file = pattern(source_file)
|
||||
if test_file.exists():
|
||||
return test_file
|
||||
|
||||
# Return the most common pattern as expected location
|
||||
return source_file.parent / f'test_{source_file.name}'
|
||||
|
||||
def _is_test_file(self, file_path: Path) -> bool:
|
||||
"""Check if a file is a test file."""
|
||||
name = file_path.name.lower()
|
||||
return any([
|
||||
name.startswith('test_'),
|
||||
name.endswith('_test.py'),
|
||||
name.endswith('_test.js'),
|
||||
name.endswith('.test.js'),
|
||||
name.endswith('.spec.js'),
|
||||
'test' in file_path.parts,
|
||||
'tests' in file_path.parts,
|
||||
])
|
||||
|
||||
def _calculate_tdd_score(self):
|
||||
"""Calculate an overall TDD compliance score (0-100)."""
|
||||
if self.metrics['files_analyzed'] == 0:
|
||||
self.metrics['tdd_score'] = 0.0
|
||||
return
|
||||
|
||||
# Factors that contribute to score
|
||||
test_coverage_ratio = self.metrics['test_files_found'] / self.metrics['files_analyzed']
|
||||
smell_penalty = min(self.metrics['code_smells'] * 5, 50) # Max 50 point penalty
|
||||
|
||||
# Score calculation
|
||||
score = (test_coverage_ratio * 100) - smell_penalty
|
||||
self.metrics['tdd_score'] = max(0.0, min(100.0, score))
|
||||
|
||||
def _get_compliance_level(self) -> str:
|
||||
"""Get human-readable compliance level."""
|
||||
score = self.metrics['tdd_score']
|
||||
|
||||
if score >= 90:
|
||||
return 'excellent'
|
||||
elif score >= 75:
|
||||
return 'good'
|
||||
elif score >= 50:
|
||||
return 'fair'
|
||||
elif score >= 25:
|
||||
return 'poor'
|
||||
else:
|
||||
return 'critical'
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the TDD compliance checker."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: check_tdd_compliance.py <path>")
|
||||
print(" path: File or directory to analyze")
|
||||
sys.exit(1)
|
||||
|
||||
path = sys.argv[1]
|
||||
|
||||
if not os.path.exists(path):
|
||||
print(f"Error: Path '{path}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
checker = TDDComplianceChecker(path)
|
||||
results = checker.analyze()
|
||||
|
||||
# Output results as JSON
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
# Exit with appropriate code
|
||||
if results['compliance'] in ['critical', 'poor']:
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user