aipr / src /analyzer.py
jarvisemitra's picture
Upload src/analyzer.py with huggingface_hub
405dc3d verified
Raw
History Blame Contribute Delete
13 kB
"""
Code Analyzer - Analyzes code structure and maps requirements to code
"""
import re
from typing import List, Dict, Any, Optional, Tuple
from src.types import Requirement, CodeMapping, Risk, RiskLevel, RiskType, PRDiff, DiffFile, RequirementStatus
from src.config import get_config
from src.ai_client import get_ai_client, AIClient
class CodeAnalyzer:
"""Analyzes code and maps requirements to specific code locations"""
def __init__(self, ai_client: Optional[AIClient] = None):
self.config = get_config()
self.ai = ai_client or get_ai_client()
def map_requirements_to_code(
self,
requirements: List[Requirement],
pr_diff: PRDiff
) -> List[CodeMapping]:
"""Map each requirement to code evidence in the PR diff"""
mappings = []
for requirement in requirements:
requirement_mappings = self._map_single_requirement(requirement, pr_diff)
mappings.extend(requirement_mappings)
# Update requirement status based on findings
if requirement_mappings:
requirement.status = RequirementStatus.PASS
requirement.evidence = [m.description for m in requirement_mappings]
else:
requirement.status = RequirementStatus.FAIL
requirement.evidence = ["No code evidence found in PR"]
return mappings
def _map_single_requirement(
self,
requirement: Requirement,
pr_diff: PRDiff
) -> List[CodeMapping]:
"""Map a single requirement to code using pattern matching and AI"""
mappings = []
req_text = requirement.text.lower()
# Define patterns to search for based on requirement keywords
search_patterns = self._get_search_patterns(requirement)
for diff_file in pr_diff.files_changed:
patch = diff_file.patch
for pattern_name, pattern in search_patterns.items():
matches = list(re.finditer(pattern, patch, re.IGNORECASE | re.MULTILINE))
for match in matches:
# Extract context around the match
start = max(0, match.start() - 100)
end = min(len(patch), match.end() + 100)
context = patch[start:end]
# Calculate line number (approximate)
line_number = patch[:match.start()].count('\n') + 1
mapping = CodeMapping(
requirement_id=requirement.id,
file_path=diff_file.filename,
line_number=line_number,
code_snippet=context.strip(),
description=f"Found {pattern_name} in {diff_file.filename}",
)
mappings.append(mapping)
# If no pattern matches, try AI-assisted mapping
if not mappings:
mappings = self._ai_map_requirement(requirement, pr_diff)
return mappings
def _get_search_patterns(self, requirement: Requirement) -> Dict[str, str]:
"""Get search patterns based on requirement keywords"""
req_text = requirement.text.lower()
patterns = {}
# Time-based patterns (e.g., 15 minutes)
if 'minute' in req_text or 'expir' in req_text:
time_match = re.search(r'(\d+)\s*minute', req_text)
if time_match:
minutes = time_match.group(1)
patterns['expiry_time'] = rf'(?:timedelta|minutes|expires?).*{minutes}'
patterns['expiry_check'] = rf'(?:expiry|valid|now\s*<|datetime\.now\(\)).*{minutes}'
# Password length patterns
if '8' in req_text and ('character' in req_text or 'length' in req_text):
patterns['length_check'] = r'len\([^)]+\)\s*[><=]+\s*\d+'
patterns['min_length'] = r'min[_ ]?length["\']?\s*[:=]\s*\d+'
# Uppercase patterns
if 'uppercase' in req_text or 'upper' in req_text:
patterns['uppercase'] = r'(?:isupper|upper|uppercase)'
# Number/digit patterns
if 'number' in req_text or 'digit' in req_text or 'numeric' in req_text:
patterns['number'] = r'(?:isdigit|isnumeric|any\(.*isdigit)'
# Hashing patterns
if 'hash' in req_text:
patterns['hashing'] = r'(?:hash|hashlib|bcrypt|argon2|pbkdf2)'
# Email patterns
if 'email' in req_text:
patterns['email'] = r'(?:email|send.*mail|smtp)'
# Session patterns
if 'session' in req_text and ('invalidat' in req_text or 'terminat' in req_text):
patterns['session_invalidation'] = r'(?:session).*(?:delete|invalidate|remove|clear)'
return patterns
def _ai_map_requirement(
self,
requirement: Requirement,
pr_diff: PRDiff
) -> List[CodeMapping]:
"""Use AI to find code evidence for a requirement"""
system_prompt = """You are a code analyzer. Given a requirement and a PR diff,
find specific code that implements or relates to that requirement.
Return a JSON array of findings with: file_path, line_number (approximate), code_snippet, description."""
diff_text = self._format_diff_for_ai(pr_diff)
prompt = f"""Find code that satisfies this requirement:
Requirement: {requirement.text}
ID: {requirement.id}
PR Diff:
{diff_text}
Look for code that implements the requirement. Be specific about file names and line numbers."""
try:
result = self.ai.complete_with_json(prompt, system_prompt)
findings = result.get('findings', [])
mappings = []
for finding in findings:
mapping = CodeMapping(
requirement_id=requirement.id,
file_path=finding.get('file_path', ''),
line_number=finding.get('line_number', 1),
code_snippet=finding.get('code_snippet', ''),
description=finding.get('description', ''),
)
mappings.append(mapping)
return mappings
except Exception:
return []
def _format_diff_for_ai(self, pr_diff: PRDiff) -> str:
"""Format PR diff for AI consumption"""
lines = []
for f in pr_diff.files_changed:
lines.append(f"=== {f.filename} ({f.status}) ===")
# Just show first 50 lines of patch to avoid token limits
patch_lines = f.patch.split('\n')[:50]
lines.extend(patch_lines)
lines.append("")
return '\n'.join(lines)
def detect_risks(self, pr_diff: PRDiff) -> List[Risk]:
"""Detect security and performance risks in the PR diff"""
risks = []
# Pattern-based risk detection
for diff_file in pr_diff.files_changed:
file_risks = self._detect_file_risks(diff_file)
risks.extend(file_risks)
# AI-powered risk detection
ai_risks = self._ai_detect_risks(pr_diff)
risks.extend(ai_risks)
return risks
def _detect_file_risks(self, diff_file: DiffFile) -> List[Risk]:
"""Detect risks in a single file"""
risks = []
patch = diff_file.patch
filename = diff_file.filename
# Security risk: Plaintext password/token storage
if re.search(r'password.*=.*["\'](?!hashed|hash|bcrypt)', patch, re.I):
risks.append(Risk(
type=RiskType.SECURITY,
level=RiskLevel.HIGH,
title="Potential plaintext password storage",
description="Found potential plaintext password assignment. Use secure hashing.",
file_path=filename,
suggestion="Use bcrypt or Argon2 for password hashing",
cwe_id="CWE-916",
))
# Security risk: Token not hashed
if re.search(r'token.*=.*["\']', patch, re.I) and not re.search(r'hash', patch, re.I):
risks.append(Risk(
type=RiskType.SECURITY,
level=RiskLevel.HIGH,
title="Token stored without hashing",
description="Reset token stored in plaintext. Should be hashed before storage.",
file_path=filename,
suggestion="Hash tokens using hashlib before storing",
cwe_id="CWE-916",
))
# Security risk: SQL injection potential
if re.search(r'execute.*\%s.*\+', patch, re.I):
risks.append(Risk(
type=RiskType.SECURITY,
level=RiskLevel.CRITICAL,
title="Potential SQL injection vulnerability",
description="String concatenation in SQL query could lead to SQL injection.",
file_path=filename,
suggestion="Use parameterized queries exclusively",
cwe_id="CWE-89",
))
# Performance risk: N+1 query pattern
if re.search(r'for .* in .*:\s*.*execute', patch, re.MULTILINE):
risks.append(Risk(
type=RiskType.PERFORMANCE,
level=RiskLevel.MEDIUM,
title="Potential N+1 query pattern",
description="Loop with database queries could cause performance issues.",
file_path=filename,
suggestion="Use bulk operations or JOINs instead of loops",
))
# Edge case: No validation
if 'validate' not in patch.lower() and 'check' not in patch.lower():
if diff_file.status == 'added':
risks.append(Risk(
type=RiskType.EDGE_CASE,
level=RiskLevel.LOW,
title="Missing input validation",
description="New file added without obvious validation logic.",
file_path=filename,
suggestion="Add input validation",
))
# Testing risk: No test coverage
if not filename.startswith('test') and not filename.startswith('tests/'):
if diff_file.status == 'added' and 'def ' in patch:
# Check if corresponding test exists
risks.append(Risk(
type=RiskType.TESTING,
level=RiskLevel.INFO,
title="New function without tests",
description=f"Added {filename} but no test file found.",
file_path=filename,
suggestion="Add unit tests for new functionality",
))
return risks
def _ai_detect_risks(self, pr_diff: PRDiff) -> List[Risk]:
"""Use AI to detect additional risks"""
system_prompt = """You are a security expert reviewing code. Identify security, performance,
and edge case risks in the code. Return a JSON array of risks with: type (SECURITY, PERFORMANCE, EDGE_CASE),
level (CRITICAL, HIGH, MEDIUM, LOW), title, description, file_path, suggestion."""
diff_text = self._format_diff_for_ai(pr_diff)
prompt = f"""Analyze this PR for risks:
{diff_text}
Focus on:
1. Security vulnerabilities (SQL injection, XSS, auth issues)
2. Performance problems (N+1, inefficient algorithms)
3. Missing edge case handling
4. Error handling issues
Return only the risks found, formatted as JSON."""
try:
result = self.ai.complete_with_json(prompt, system_prompt)
risks_data = result.get('risks', [])
risks = []
for risk_data in risks_data:
risk = Risk(
type=RiskType(risk_data.get('type', 'CODE_QUALITY').upper()),
level=RiskLevel(risk_data.get('level', 'LOW').upper()),
title=risk_data.get('title', ''),
description=risk_data.get('description', ''),
file_path=risk_data.get('file_path'),
suggestion=risk_data.get('suggestion'),
)
risks.append(risk)
return risks
except Exception:
return []