mirror of
https://github.com/Jaganpro/sf-skills.git
synced 2026-09-19 07:52:00 +08:00
1 feat(shared): Integrate Salesforce Code Analyzer V5 with hook system
2 3 Adds OOTB linting via sf code-analyzer CLI to augment custom 150-point 4 scoring. Hooks auto-trigger on Write/Edit of Apex and Flow files. 5 6 NEW INFRASTRUCTURE (shared/code_analyzer/): 7 - scanner.py: Core wrapper for `sf code-analyzer run` CLI 8 - dependency_checker.py: Multi-path Java detection (Homebrew support) 9 - score_merger.py: Combines custom scoring with CA violations 10 - parser.py: JSON result normalization 11 - formatter.py: Terminal output formatting 12 - config/code-analyzer.yml: Engine configuration 13 14 HOOK INTEGRATION: 15 - sf-apex/hooks: Added Edit matcher, post-tool-validate.py 16 - sf-flow/hooks: Added Edit matcher, post-tool-validate.py 17 - Timeout increased to 120s for CA scans 18 19 KEY FEATURES: 20 - 7 engines: PMD, CPD, SFGE, ESLint, RetireJS, Flow Scanner, Regex 21 - Graceful degradation when deps missing (Java, Node, Python) 22 - Auto-detects Java in Homebrew paths (/opt/homebrew/opt/openjdk@*) 23 - Propagates JAVA_HOME to sf CLI subprocess 24 - Filters engine errors from violation output 25 26 FIXES INCLUDED: 27 - Java detection: Handles Salesforce wrapper scripts 28 - SFGE errors: No longer shown as CRITICAL violations 29 - Class name parsing: Excludes "class" keyword in comments 30 - Flow Python: Explicit python_command in config 31 32 VALIDATION OUTPUT FORMAT: 33 🔍 Apex Validation: AccountService.cls 34 📊 Score: 138/150 ⭐⭐⭐⭐ Very Good 35 🔬 Code Analyzer: pmd, regex (3482ms) 36 ❗ Issues: [sf-skills] + [CA:pmd] combined 37 38 STATS: 14 files changed, ~3500 insertions
This commit is contained in:
@@ -6,8 +6,18 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-write-validate.py",
|
||||
"timeout": 60000
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-tool-validate.py",
|
||||
"timeout": 120000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-tool-validate.py",
|
||||
"timeout": 120000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post-Tool Validation Hook for sf-apex plugin.
|
||||
|
||||
This hook runs AFTER Write or Edit tool completes and provides validation
|
||||
feedback for Salesforce Apex files (*.cls, *.trigger).
|
||||
|
||||
Integrates:
|
||||
1. Custom 150-point scoring (8 categories)
|
||||
2. Salesforce Code Analyzer V5 (all available engines)
|
||||
|
||||
Hook Input (stdin): JSON with tool_input and tool_response
|
||||
Hook Output (stdout): JSON with optional output message
|
||||
|
||||
This hook is ADVISORY - it provides feedback but does not block operations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# Add script directory to path for imports
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
# Find shared modules (../../shared relative to sf-apex)
|
||||
PLUGIN_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # sf-apex/
|
||||
SKILLS_ROOT = os.path.dirname(PLUGIN_ROOT) # sf-skills/
|
||||
SHARED_DIR = os.path.join(SKILLS_ROOT, "shared")
|
||||
sys.path.insert(0, SHARED_DIR)
|
||||
|
||||
|
||||
def validate_apex_with_ca(file_path: str) -> dict:
|
||||
"""
|
||||
Run comprehensive Apex validation combining custom scoring with Code Analyzer.
|
||||
|
||||
Args:
|
||||
file_path: Path to .cls or .trigger file
|
||||
|
||||
Returns:
|
||||
dict with validation results and output message
|
||||
"""
|
||||
output_parts = []
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 1: Custom 150-point validation
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
from validate_apex import ApexValidator
|
||||
|
||||
validator = ApexValidator(file_path)
|
||||
custom_results = validator.validate()
|
||||
|
||||
custom_score = custom_results.get('score', 0)
|
||||
custom_max = custom_results.get('max_score', 150)
|
||||
custom_issues = custom_results.get('issues', [])
|
||||
custom_scores = custom_results.get('scores', {})
|
||||
custom_rating = custom_results.get('rating', '')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 2: Code Analyzer V5 scanning (if available)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
ca_violations = []
|
||||
ca_engines_used = []
|
||||
ca_engines_unavailable = []
|
||||
ca_available = False
|
||||
scan_time_ms = 0
|
||||
|
||||
try:
|
||||
from code_analyzer.scanner import CodeAnalyzerScanner, SkillType
|
||||
from code_analyzer.score_merger import ScoreMerger
|
||||
|
||||
scanner = CodeAnalyzerScanner()
|
||||
|
||||
if scanner.is_available():
|
||||
ca_available = True
|
||||
scan_result = scanner.scan(file_path, SkillType.APEX)
|
||||
|
||||
if scan_result.success:
|
||||
ca_violations = scan_result.violations
|
||||
ca_engines_used = scan_result.engines_used
|
||||
ca_engines_unavailable = scan_result.engines_unavailable
|
||||
scan_time_ms = scan_result.scan_time_ms
|
||||
else:
|
||||
ca_engines_unavailable = ["Error: " + (scan_result.error_message or "Unknown")]
|
||||
else:
|
||||
ca_engines_unavailable = ["sf CLI with Code Analyzer not installed"]
|
||||
|
||||
except ImportError as e:
|
||||
ca_engines_unavailable = [f"Module not available: {e}"]
|
||||
except Exception as e:
|
||||
ca_engines_unavailable = [f"Scanner error: {e}"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 3: Merge scores (if CA results available)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
final_score = custom_score
|
||||
final_max = custom_max
|
||||
rating = custom_rating
|
||||
rating_stars = 0
|
||||
ca_deductions = 0
|
||||
deductions = []
|
||||
|
||||
if ca_violations and ca_available:
|
||||
try:
|
||||
merger = ScoreMerger(
|
||||
custom_scores=custom_scores,
|
||||
custom_max_scores=validator.scores
|
||||
)
|
||||
merged = merger.merge(
|
||||
[v if isinstance(v, dict) else v.__dict__ for v in ca_violations],
|
||||
engines_used=ca_engines_used,
|
||||
engines_unavailable=ca_engines_unavailable,
|
||||
)
|
||||
final_score = merged.final_score
|
||||
final_max = merged.final_max
|
||||
rating = merged.rating
|
||||
rating_stars = merged.rating_stars
|
||||
ca_deductions = merged.ca_deductions
|
||||
deductions = merged.deductions
|
||||
except Exception as e:
|
||||
# Fallback to custom score only
|
||||
pass
|
||||
|
||||
# Calculate rating stars from custom score if not set
|
||||
if rating_stars == 0:
|
||||
pct = (final_score / final_max * 100) if final_max > 0 else 0
|
||||
if pct >= 90:
|
||||
rating_stars = 5
|
||||
elif pct >= 75:
|
||||
rating_stars = 4
|
||||
elif pct >= 60:
|
||||
rating_stars = 3
|
||||
elif pct >= 45:
|
||||
rating_stars = 2
|
||||
else:
|
||||
rating_stars = 1
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 4: Format output
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
stars = "" * rating_stars + "" * (5 - rating_stars)
|
||||
|
||||
output_parts.append("")
|
||||
output_parts.append(f" Apex Validation: {file_name}")
|
||||
output_parts.append("" * 60)
|
||||
|
||||
# Combined score
|
||||
output_parts.append(f" Score: {final_score}/{final_max} {stars} {rating}")
|
||||
|
||||
# Show CA deductions if any
|
||||
if ca_deductions > 0:
|
||||
output_parts.append(f" (Custom: {custom_score}, CA deductions: -{ca_deductions})")
|
||||
|
||||
# Category breakdown
|
||||
if custom_scores:
|
||||
output_parts.append("")
|
||||
output_parts.append(" Category Breakdown:")
|
||||
for cat, score in custom_scores.items():
|
||||
max_score = validator.scores.get(cat, 0)
|
||||
if max_score > 0:
|
||||
icon = "" if score == max_score else ("" if score >= max_score * 0.7 else "")
|
||||
diff = f" (-{max_score - score})" if score < max_score else ""
|
||||
display_name = cat.replace("_", " ").title()
|
||||
output_parts.append(f" {icon} {display_name}: {score}/{max_score}{diff}")
|
||||
|
||||
# Code Analyzer status
|
||||
output_parts.append("")
|
||||
if ca_engines_used:
|
||||
output_parts.append(f" Code Analyzer: {', '.join(ca_engines_used)}")
|
||||
elif ca_available:
|
||||
output_parts.append(" Code Analyzer: No engines ran")
|
||||
else:
|
||||
output_parts.append(" Code Analyzer: Not available")
|
||||
|
||||
if ca_engines_unavailable:
|
||||
for unavail in ca_engines_unavailable[:3]:
|
||||
output_parts.append(f" {unavail}")
|
||||
|
||||
if scan_time_ms > 0:
|
||||
output_parts.append(f" Scan time: {scan_time_ms}ms")
|
||||
|
||||
# Issues list
|
||||
all_issues = []
|
||||
|
||||
# Add custom issues
|
||||
for issue in custom_issues:
|
||||
severity = issue.get('severity', 'INFO')
|
||||
all_issues.append({
|
||||
'severity': severity,
|
||||
'source': 'sf-skills',
|
||||
'line': issue.get('line', 0),
|
||||
'message': issue.get('message', ''),
|
||||
'fix': issue.get('fix', ''),
|
||||
})
|
||||
|
||||
# Add CA violations
|
||||
for v in ca_violations:
|
||||
if isinstance(v, dict):
|
||||
all_issues.append({
|
||||
'severity': v.get('severity_label', 'INFO'),
|
||||
'source': f"CA:{v.get('engine', '')}",
|
||||
'line': v.get('line', 0),
|
||||
'message': v.get('message', '')[:80],
|
||||
'rule': v.get('rule', ''),
|
||||
})
|
||||
|
||||
if all_issues:
|
||||
output_parts.append("")
|
||||
output_parts.append(f" Issues Found ({len(all_issues)}):")
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {'CRITICAL': 0, 'HIGH': 1, 'MODERATE': 2, 'WARNING': 3, 'LOW': 4, 'INFO': 5}
|
||||
all_issues.sort(key=lambda x: severity_order.get(x['severity'], 5))
|
||||
|
||||
# Display up to 12 issues
|
||||
for issue in all_issues[:12]:
|
||||
icon = {'CRITICAL': '', 'HIGH': '', 'MODERATE': '', 'WARNING': '', 'LOW': '', 'INFO': ''}.get(
|
||||
issue['severity'], ''
|
||||
)
|
||||
source = f"[{issue['source']}]" if issue.get('source') else ""
|
||||
line_info = f"L{issue['line']}" if issue.get('line') else ""
|
||||
message = issue['message'][:65] + "..." if len(issue['message']) > 65 else issue['message']
|
||||
|
||||
output_parts.append(f" {icon} {issue['severity']} {source} {line_info}: {message}")
|
||||
|
||||
if issue.get('fix'):
|
||||
fix = issue['fix'][:55] + "..." if len(issue['fix']) > 55 else issue['fix']
|
||||
output_parts.append(f" Fix: {fix}")
|
||||
|
||||
if len(all_issues) > 12:
|
||||
output_parts.append(f" ... and {len(all_issues) - 12} more issues")
|
||||
else:
|
||||
output_parts.append("")
|
||||
output_parts.append(" No issues found!")
|
||||
|
||||
output_parts.append("" * 60)
|
||||
|
||||
return {
|
||||
"continue": True,
|
||||
"output": "\n".join(output_parts)
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
return {
|
||||
"continue": True,
|
||||
"output": f" Apex validator not available: {e}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"continue": True,
|
||||
"output": f" Apex validation error: {e}"
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main hook entry point.
|
||||
|
||||
Reads hook input from stdin, validates Apex files.
|
||||
"""
|
||||
try:
|
||||
# Read hook input from stdin
|
||||
hook_input = json.load(sys.stdin)
|
||||
|
||||
# Extract file path from tool input
|
||||
tool_input = hook_input.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
# Check if operation was successful
|
||||
tool_response = hook_input.get("tool_response", {})
|
||||
if not tool_response.get("success", True):
|
||||
# Operation failed, don't validate
|
||||
print(json.dumps({"continue": True}))
|
||||
return 0
|
||||
|
||||
# Only validate Apex files
|
||||
result = {"continue": True}
|
||||
|
||||
if file_path.endswith(".cls") or file_path.endswith(".trigger"):
|
||||
result = validate_apex_with_ca(file_path)
|
||||
|
||||
# Output result
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# No valid JSON input, continue silently
|
||||
print(json.dumps({"continue": True}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
# Unexpected error, log but don't block
|
||||
print(json.dumps({
|
||||
"continue": True,
|
||||
"output": f" Hook error: {e}"
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -258,9 +258,14 @@ class ApexValidator:
|
||||
def _check_naming_conventions(self):
|
||||
"""Check for naming convention violations."""
|
||||
# Class names should be PascalCase
|
||||
class_pattern = r'class\s+(\w+)'
|
||||
# Match actual class declarations (with optional modifiers), not "class" in comments
|
||||
class_pattern = r'^\s*(?:public|private|global|virtual|abstract|with\s+sharing|without\s+sharing|\s)*\s*class\s+(\w+)'
|
||||
for i, line in enumerate(self.lines, 1):
|
||||
match = re.search(class_pattern, line)
|
||||
# Skip comment lines
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('//') or stripped.startswith('*') or stripped.startswith('/*'):
|
||||
continue
|
||||
match = re.search(class_pattern, line, re.IGNORECASE)
|
||||
if match:
|
||||
class_name = match.group(1)
|
||||
if not class_name[0].isupper():
|
||||
|
||||
@@ -6,8 +6,18 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-write-validate.py",
|
||||
"timeout": 60000
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-tool-validate.py",
|
||||
"timeout": 120000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-tool-validate.py",
|
||||
"timeout": 120000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post-Tool Validation Hook for sf-flow plugin.
|
||||
|
||||
This hook runs AFTER Write or Edit tool completes and provides validation
|
||||
feedback for Salesforce Flow files (*.flow-meta.xml).
|
||||
|
||||
Integrates:
|
||||
1. Custom 110-point scoring (6 categories)
|
||||
2. Salesforce Code Analyzer V5 Flow Scanner
|
||||
|
||||
Hook Input (stdin): JSON with tool_input and tool_response
|
||||
Hook Output (stdout): JSON with optional output message
|
||||
|
||||
This hook is ADVISORY - it provides feedback but does not block operations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# Add script directory to path for imports
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
# Find shared modules (../../shared relative to sf-flow)
|
||||
PLUGIN_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # sf-flow/
|
||||
SKILLS_ROOT = os.path.dirname(PLUGIN_ROOT) # sf-skills/
|
||||
SHARED_DIR = os.path.join(SKILLS_ROOT, "shared")
|
||||
sys.path.insert(0, SHARED_DIR)
|
||||
|
||||
|
||||
def validate_flow_with_ca(file_path: str) -> dict:
|
||||
"""
|
||||
Run comprehensive Flow validation combining custom scoring with Code Analyzer.
|
||||
|
||||
Args:
|
||||
file_path: Path to .flow-meta.xml file
|
||||
|
||||
Returns:
|
||||
dict with validation results and output message
|
||||
"""
|
||||
output_parts = []
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 1: Custom 110-point validation
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
from validate_flow import EnhancedFlowValidator
|
||||
|
||||
validator = EnhancedFlowValidator(file_path)
|
||||
custom_results = validator.validate()
|
||||
|
||||
flow_name = custom_results.get('flow_name', 'Unknown')
|
||||
custom_score = custom_results.get('overall_score', 0)
|
||||
custom_max = 110
|
||||
custom_rating = custom_results.get('rating', '')
|
||||
|
||||
# Collect issues from all categories
|
||||
custom_issues = []
|
||||
category_scores = {}
|
||||
|
||||
for cat_name, cat_data in custom_results.get('categories', {}).items():
|
||||
score = cat_data.get('score', 0)
|
||||
max_score = cat_data.get('max_score', 0)
|
||||
category_scores[cat_name] = (score, max_score)
|
||||
|
||||
for issue in cat_data.get('issues', []):
|
||||
custom_issues.append({
|
||||
'severity': issue.get('severity', 'INFO'),
|
||||
'message': issue.get('message', ''),
|
||||
'category': cat_name,
|
||||
'fix': issue.get('fix', ''),
|
||||
})
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 2: Code Analyzer V5 Flow Scanner (if available)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
ca_violations = []
|
||||
ca_engines_used = []
|
||||
ca_engines_unavailable = []
|
||||
ca_available = False
|
||||
scan_time_ms = 0
|
||||
|
||||
try:
|
||||
from code_analyzer.scanner import CodeAnalyzerScanner, SkillType
|
||||
|
||||
scanner = CodeAnalyzerScanner()
|
||||
|
||||
if scanner.is_available():
|
||||
ca_available = True
|
||||
scan_result = scanner.scan(file_path, SkillType.FLOW)
|
||||
|
||||
if scan_result.success:
|
||||
ca_violations = scan_result.violations
|
||||
ca_engines_used = scan_result.engines_used
|
||||
ca_engines_unavailable = scan_result.engines_unavailable
|
||||
scan_time_ms = scan_result.scan_time_ms
|
||||
else:
|
||||
ca_engines_unavailable = ["Error: " + (scan_result.error_message or "Unknown")]
|
||||
else:
|
||||
ca_engines_unavailable = ["sf CLI with Code Analyzer not installed"]
|
||||
|
||||
except ImportError as e:
|
||||
ca_engines_unavailable = [f"Module not available: {e}"]
|
||||
except Exception as e:
|
||||
ca_engines_unavailable = [f"Scanner error: {e}"]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 3: Calculate final score (simple merge for Flow)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# For Flow, we count critical CA findings as additional deductions
|
||||
ca_deductions = 0
|
||||
for v in ca_violations:
|
||||
if isinstance(v, dict):
|
||||
severity = v.get('severity', 5)
|
||||
if severity == 1: # Critical
|
||||
ca_deductions += 5
|
||||
elif severity == 2: # High
|
||||
ca_deductions += 3
|
||||
ca_deductions = min(ca_deductions, 15) # Cap at 15 points
|
||||
|
||||
final_score = max(0, custom_score - ca_deductions)
|
||||
final_max = custom_max
|
||||
|
||||
# Determine rating
|
||||
pct = (final_score / final_max * 100) if final_max > 0 else 0
|
||||
if pct >= 90:
|
||||
rating_stars = 5
|
||||
rating = "Excellent"
|
||||
elif pct >= 75:
|
||||
rating_stars = 4
|
||||
rating = "Very Good"
|
||||
elif pct >= 60:
|
||||
rating_stars = 3
|
||||
rating = "Good"
|
||||
elif pct >= 45:
|
||||
rating_stars = 2
|
||||
rating = "Needs Work"
|
||||
else:
|
||||
rating_stars = 1
|
||||
rating = "Critical Issues"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PHASE 4: Format output
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
stars = "" * rating_stars + "" * (5 - rating_stars)
|
||||
|
||||
output_parts.append("")
|
||||
output_parts.append(f" Flow Validation: {flow_name}")
|
||||
output_parts.append("" * 60)
|
||||
|
||||
# Combined score
|
||||
output_parts.append(f" Score: {final_score}/{final_max} {stars} {rating}")
|
||||
|
||||
# Show CA deductions if any
|
||||
if ca_deductions > 0:
|
||||
output_parts.append(f" (Custom: {custom_score}, CA deductions: -{ca_deductions})")
|
||||
|
||||
# Category breakdown
|
||||
if category_scores:
|
||||
output_parts.append("")
|
||||
output_parts.append(" Category Breakdown:")
|
||||
for cat, (score, max_score) in category_scores.items():
|
||||
if max_score > 0:
|
||||
icon = "" if score == max_score else ("" if score >= max_score * 0.7 else "")
|
||||
diff = f" (-{max_score - score})" if score < max_score else ""
|
||||
display_name = cat.replace("_", " ").title()
|
||||
output_parts.append(f" {icon} {display_name}: {score}/{max_score}{diff}")
|
||||
|
||||
# Code Analyzer status
|
||||
output_parts.append("")
|
||||
if ca_engines_used:
|
||||
output_parts.append(f" Code Analyzer: {', '.join(ca_engines_used)}")
|
||||
elif ca_available:
|
||||
output_parts.append(" Code Analyzer: No engines ran")
|
||||
else:
|
||||
output_parts.append(" Code Analyzer: Not available")
|
||||
|
||||
if ca_engines_unavailable:
|
||||
for unavail in ca_engines_unavailable[:3]:
|
||||
output_parts.append(f" {unavail}")
|
||||
|
||||
if scan_time_ms > 0:
|
||||
output_parts.append(f" Scan time: {scan_time_ms}ms")
|
||||
|
||||
# Issues list
|
||||
all_issues = []
|
||||
|
||||
# Add custom issues
|
||||
for issue in custom_issues:
|
||||
all_issues.append({
|
||||
'severity': issue.get('severity', 'INFO'),
|
||||
'source': 'sf-skills',
|
||||
'message': issue.get('message', ''),
|
||||
'fix': issue.get('fix', ''),
|
||||
})
|
||||
|
||||
# Add CA violations
|
||||
for v in ca_violations:
|
||||
if isinstance(v, dict):
|
||||
all_issues.append({
|
||||
'severity': v.get('severity_label', 'INFO'),
|
||||
'source': f"CA:{v.get('engine', '')}",
|
||||
'message': v.get('message', '')[:80],
|
||||
'rule': v.get('rule', ''),
|
||||
})
|
||||
|
||||
if all_issues:
|
||||
output_parts.append("")
|
||||
output_parts.append(f" Issues Found ({len(all_issues)}):")
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {'CRITICAL': 0, 'HIGH': 1, 'MODERATE': 2, 'WARNING': 3, 'LOW': 4, 'INFO': 5}
|
||||
all_issues.sort(key=lambda x: severity_order.get(x['severity'], 5))
|
||||
|
||||
# Display up to 12 issues
|
||||
for issue in all_issues[:12]:
|
||||
icon = {'CRITICAL': '', 'HIGH': '', 'MODERATE': '', 'WARNING': '', 'LOW': '', 'INFO': ''}.get(
|
||||
issue['severity'], ''
|
||||
)
|
||||
source = f"[{issue['source']}]" if issue.get('source') else ""
|
||||
message = issue['message'][:65] + "..." if len(issue['message']) > 65 else issue['message']
|
||||
|
||||
output_parts.append(f" {icon} {issue['severity']} {source}: {message}")
|
||||
|
||||
if issue.get('fix'):
|
||||
fix = issue['fix'][:55] + "..." if len(issue['fix']) > 55 else issue['fix']
|
||||
output_parts.append(f" Fix: {fix}")
|
||||
|
||||
if len(all_issues) > 12:
|
||||
output_parts.append(f" ... and {len(all_issues) - 12} more issues")
|
||||
else:
|
||||
output_parts.append("")
|
||||
output_parts.append(" No issues found!")
|
||||
|
||||
output_parts.append("" * 60)
|
||||
|
||||
return {
|
||||
"continue": True,
|
||||
"output": "\n".join(output_parts)
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
return {
|
||||
"continue": True,
|
||||
"output": f" Flow validator not available: {e}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"continue": True,
|
||||
"output": f" Flow validation error: {e}"
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main hook entry point.
|
||||
|
||||
Reads hook input from stdin, validates Flow files.
|
||||
"""
|
||||
try:
|
||||
# Read hook input from stdin
|
||||
hook_input = json.load(sys.stdin)
|
||||
|
||||
# Extract file path from tool input
|
||||
tool_input = hook_input.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
# Check if operation was successful
|
||||
tool_response = hook_input.get("tool_response", {})
|
||||
if not tool_response.get("success", True):
|
||||
# Operation failed, don't validate
|
||||
print(json.dumps({"continue": True}))
|
||||
return 0
|
||||
|
||||
# Only validate Flow files
|
||||
result = {"continue": True}
|
||||
|
||||
if file_path.endswith(".flow-meta.xml"):
|
||||
result = validate_flow_with_ca(file_path)
|
||||
|
||||
# Output result
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# No valid JSON input, continue silently
|
||||
print(json.dumps({"continue": True}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
# Unexpected error, log but don't block
|
||||
print(json.dumps({
|
||||
"continue": True,
|
||||
"output": f" Hook error: {e}"
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Salesforce Code Analyzer V5 Integration for sf-skills.
|
||||
|
||||
This module provides shared infrastructure for integrating Salesforce Code Analyzer
|
||||
with Claude Code's hook system across all sf-skills (apex, flow, lwc, etc.).
|
||||
|
||||
Components:
|
||||
- scanner: Core wrapper for sf code-analyzer CLI
|
||||
- parser: JSON result normalization
|
||||
- dependency_checker: Runtime dependency detection (JDK, Node, Python)
|
||||
- score_merger: Combines custom scoring with CA findings
|
||||
- formatter: Terminal output formatting
|
||||
|
||||
Usage:
|
||||
from code_analyzer import CodeAnalyzerScanner, SkillType, ScoreMerger
|
||||
|
||||
scanner = CodeAnalyzerScanner()
|
||||
result = scanner.scan("/path/to/file.cls", SkillType.APEX)
|
||||
|
||||
merger = ScoreMerger(custom_scores, max_scores)
|
||||
merged = merger.merge(result.violations)
|
||||
"""
|
||||
|
||||
from .scanner import CodeAnalyzerScanner, SkillType, ScanResult
|
||||
from .dependency_checker import DependencyChecker
|
||||
from .score_merger import ScoreMerger, MergedScore
|
||||
from .parser import parse_ca_output, normalize_violation
|
||||
from .formatter import format_validation_output
|
||||
|
||||
__all__ = [
|
||||
# Scanner
|
||||
"CodeAnalyzerScanner",
|
||||
"SkillType",
|
||||
"ScanResult",
|
||||
# Dependencies
|
||||
"DependencyChecker",
|
||||
# Scoring
|
||||
"ScoreMerger",
|
||||
"MergedScore",
|
||||
# Parser
|
||||
"parse_ca_output",
|
||||
"normalize_violation",
|
||||
# Formatter
|
||||
"format_validation_output",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,50 @@
|
||||
# =============================================================================
|
||||
# Salesforce Code Analyzer V5 Configuration for sf-skills
|
||||
# =============================================================================
|
||||
# This configuration augments the 150-point custom scoring with OOTB engines.
|
||||
# All engines enabled by default with graceful degradation when deps missing.
|
||||
#
|
||||
# Documentation: https://developer.salesforce.com/docs/platform/salesforce-code-analyzer
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Engine Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Note: Using default PMD rules (80+ Apex rules) which cover:
|
||||
# - Bulkification (OperationWithLimitsInLoop, AvoidSoqlInLoops)
|
||||
# - Security (ApexCRUDViolation, ApexSharingViolations, ApexSOQLInjection)
|
||||
# - Testing (ApexUnitTestClassShouldHaveAsserts, ApexUnitTestShouldNotUseSeeAllDataTrue)
|
||||
# - Clean Code (CognitiveComplexity, EmptyCatchBlock, AvoidDeeplyNestedIfStmts)
|
||||
# - Documentation (ApexDoc)
|
||||
# -----------------------------------------------------------------------------
|
||||
engines:
|
||||
# PMD - Static analysis for Apex, Visualforce, XML (requires JDK 11+)
|
||||
pmd:
|
||||
disable_engine: false
|
||||
|
||||
# CPD - Copy-Paste Detection (requires JDK 11+)
|
||||
# Note: cpd configuration requires special format, using defaults
|
||||
cpd:
|
||||
disable_engine: false
|
||||
|
||||
# Salesforce Graph Engine - Data flow analysis (requires JDK 11+)
|
||||
sfge:
|
||||
disable_engine: false
|
||||
|
||||
# ESLint - JavaScript/LWC linting (requires Node.js)
|
||||
eslint:
|
||||
disable_engine: false
|
||||
|
||||
# RetireJS - JavaScript dependency vulnerability scanning (requires Node.js)
|
||||
retire-js:
|
||||
disable_engine: false
|
||||
|
||||
# Flow Scanner - Salesforce Flow validation (requires Python 3.10+)
|
||||
flow:
|
||||
disable_engine: false
|
||||
# Explicit Python path for environments where sf CLI can't find it
|
||||
python_command: /opt/homebrew/opt/python@3.14/bin/python3
|
||||
|
||||
# Regex - Pattern-based rules (no external dependencies)
|
||||
regex:
|
||||
disable_engine: false
|
||||
@@ -0,0 +1,287 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
sf-skills Custom PMD Ruleset for Apex
|
||||
=====================================
|
||||
Complements the 150-point custom scoring system with additional PMD rules
|
||||
aligned to sf-skills categories: Bulkification, Security, Testing,
|
||||
Architecture, Clean Code, Error Handling, Performance, Documentation.
|
||||
|
||||
PMD Documentation: https://pmd.github.io/pmd/pmd_rules_apex.html
|
||||
-->
|
||||
<ruleset name="sf-skills Apex Rules"
|
||||
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
|
||||
<description>
|
||||
Custom PMD ruleset for sf-skills Claude Code plugin.
|
||||
Focuses on Salesforce best practices and 2025 patterns including
|
||||
Trigger Actions Framework (TAF), User Mode queries, and Assert class.
|
||||
</description>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- BULKIFICATION RULES (Category: 25 points) -->
|
||||
<!-- Critical violations that cause governor limit failures -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/performance.xml/AvoidSoqlInLoops">
|
||||
<priority>1</priority>
|
||||
<properties>
|
||||
<property name="violationSuppressXPath"
|
||||
value="//UserClass[@SimpleName[ends-with(., 'Test')]]"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/performance.xml/AvoidDmlStatementsInLoops">
|
||||
<priority>1</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/performance.xml/OperationWithLimitsInLoop">
|
||||
<priority>1</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- SECURITY RULES (Category: 25 points) -->
|
||||
<!-- CRUD, FLS, Sharing, Injection vulnerabilities -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexCRUDViolation">
|
||||
<priority>2</priority>
|
||||
<properties>
|
||||
<!-- Recommend User Mode instead of manual CRUD checks in 2025 -->
|
||||
<property name="createAuthMethodPattern" value=""/>
|
||||
<property name="readAuthMethodPattern" value=""/>
|
||||
<property name="updateAuthMethodPattern" value=""/>
|
||||
<property name="deleteAuthMethodPattern" value=""/>
|
||||
<property name="undeleteAuthMethodPattern" value=""/>
|
||||
<property name="mergeAuthMethodPattern" value=""/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexSharingViolations">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexSOQLInjection">
|
||||
<priority>1</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexOpenRedirect">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexCSRF">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexXSSFromURLParam">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexXSSFromEscapeFalse">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexBadCrypto">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexInsecureEndpoint">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/security.xml/ApexSuggestUsingNamedCred">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- TESTING RULES (Category: 25 points) -->
|
||||
<!-- Test quality and coverage patterns -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/ApexUnitTestClassShouldHaveAsserts">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/ApexUnitTestShouldNotUseSeeAllDataTrue">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/ApexAssertionsShouldIncludeMessage">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/ApexUnitTestMethodShouldHaveIsTestAnnotation">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- ARCHITECTURE RULES (Category: 20 points) -->
|
||||
<!-- Trigger patterns, separation of concerns -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/AvoidLogicInTrigger">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/AvoidGlobalModifier">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- CLEAN CODE RULES (Category: 20 points) -->
|
||||
<!-- Complexity, naming, maintainability -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/design.xml/CyclomaticComplexity">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="classReportLevel" value="80"/>
|
||||
<property name="methodReportLevel" value="10"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/CognitiveComplexity">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="classReportLevel" value="50"/>
|
||||
<property name="methodReportLevel" value="15"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/ExcessiveParameterList">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="minimum" value="5"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/ExcessiveClassLength">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="minimum" value="1000"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/ExcessivePublicCount">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="minimum" value="25"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/TooManyFields">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="maxfields" value="20"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/NcssMethodCount">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="minimum" value="60"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/design.xml/NcssTypeCount">
|
||||
<priority>3</priority>
|
||||
<properties>
|
||||
<property name="minimum" value="700"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/AvoidHardcodingId">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/codestyle.xml/FieldNamingConventions">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/codestyle.xml/FormalParameterNamingConventions">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/codestyle.xml/LocalVariableNamingConventions">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/codestyle.xml/MethodNamingConventions">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/codestyle.xml/ClassNamingConventions">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- ERROR HANDLING RULES (Category: 15 points) -->
|
||||
<!-- Exception handling patterns -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/EmptyCatchBlock">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/EmptyTryOrFinallyBlock">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/EmptyStatementBlock">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/EmptyIfStmt">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/EmptyWhileStmt">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- PERFORMANCE RULES (Category: 10 points) -->
|
||||
<!-- Governor limits, efficiency -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/performance.xml/EagerlyLoadedDescribeSObjectResult">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- DOCUMENTATION RULES (Category: 10 points) -->
|
||||
<!-- ApexDoc comments -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/documentation.xml/ApexDoc">
|
||||
<priority>4</priority>
|
||||
<properties>
|
||||
<property name="reportMissingDescription" value="true"/>
|
||||
<property name="reportPrivate" value="false"/>
|
||||
<property name="reportProtected" value="true"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- ERROR-PRONE RULES -->
|
||||
<!-- Common mistakes and anti-patterns -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/MethodWithSameNameAsEnclosingClass">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/errorprone.xml/InaccessibleAuraEnabledGetter">
|
||||
<priority>2</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/DebugsShouldUseLoggingLevel">
|
||||
<priority>4</priority>
|
||||
</rule>
|
||||
|
||||
<rule ref="category/apex/bestpractices.xml/UnusedLocalVariable">
|
||||
<priority>3</priority>
|
||||
</rule>
|
||||
|
||||
</ruleset>
|
||||
@@ -0,0 +1,225 @@
|
||||
# =============================================================================
|
||||
# sf-skills Custom Regex Patterns for Code Analyzer V5
|
||||
# =============================================================================
|
||||
# Pattern-based rules to complement PMD analysis.
|
||||
# These catch Salesforce-specific anti-patterns that PMD may miss.
|
||||
#
|
||||
# Regex Format Requirements:
|
||||
# - Pattern must include global modifier (e.g., /pattern/gi)
|
||||
# - Use file_extensions to target specific file types
|
||||
#
|
||||
# Documentation: https://developer.salesforce.com/docs/platform/salesforce-code-analyzer
|
||||
# =============================================================================
|
||||
|
||||
custom_rules:
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# APEX PATTERNS (.cls, .trigger)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SECURITY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
- name: HardcodedSalesforceUrl
|
||||
description: "Hardcoded Salesforce URL won't work across orgs"
|
||||
message: "Use URL.getOrgDomainUrl() or Named Credentials instead of hardcoded Salesforce URLs"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
- ".trigger"
|
||||
regex: /https?:\/\/[a-zA-Z0-9-]+\.(salesforce|force|my\.salesforce|lightning\.force)\.com/gi
|
||||
severity: 2
|
||||
tags:
|
||||
- Security
|
||||
- BestPractices
|
||||
- sf-skills
|
||||
|
||||
- name: HardcodedSalesforceId
|
||||
description: "Hardcoded 15 or 18 character Salesforce ID"
|
||||
message: "Use Custom Metadata, Custom Settings, or SOQL to retrieve IDs dynamically"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
- ".trigger"
|
||||
# Matches common Salesforce ID prefixes (001=Account, 003=Contact, 005=User, etc.)
|
||||
regex: /['"][0-9a-zA-Z]{15}['"]|['"][0-9a-zA-Z]{18}['"]/g
|
||||
severity: 3
|
||||
tags:
|
||||
- BestPractices
|
||||
- CleanCode
|
||||
- sf-skills
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TESTING
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
- name: DeprecatedTestIsRunning
|
||||
description: "Using deprecated Test.isRunningTest() pattern"
|
||||
message: "Use @TestVisible annotation or dependency injection instead of Test.isRunningTest()"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
regex: /Test\.isRunningTest\s*\(\s*\)/gi
|
||||
severity: 3
|
||||
tags:
|
||||
- Testing
|
||||
- BestPractices
|
||||
- sf-skills
|
||||
|
||||
- name: OldAssertMethods
|
||||
description: "Using deprecated System.assert methods instead of Assert class"
|
||||
message: "Use Assert.areEqual(), Assert.isTrue(), etc. instead of System.assert* (Winter '23+)"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
regex: /System\.(assert|assertEquals|assertNotEquals)\s*\(/gi
|
||||
severity: 4
|
||||
tags:
|
||||
- Testing
|
||||
- BestPractices
|
||||
- sf-skills
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLEAN CODE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
- name: DebugWithoutLoggingLevel
|
||||
description: "System.debug without logging level"
|
||||
message: "Use System.debug(LoggingLevel.INFO, msg) to control log visibility"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
- ".trigger"
|
||||
regex: /System\.debug\s*\(\s*[^L]/gi
|
||||
severity: 4
|
||||
tags:
|
||||
- CleanCode
|
||||
- Debugging
|
||||
- sf-skills
|
||||
|
||||
- name: TodoComment
|
||||
description: "TODO comment found - track in issue tracker instead"
|
||||
message: "Consider tracking TODOs in your issue tracker for visibility"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
- ".trigger"
|
||||
regex: /\/\/\s*TODO|\/\*\s*TODO/gi
|
||||
severity: 5
|
||||
tags:
|
||||
- Documentation
|
||||
- sf-skills
|
||||
|
||||
- name: FixmeComment
|
||||
description: "FIXME comment indicates known issue"
|
||||
message: "FIXME comments indicate technical debt - prioritize resolution"
|
||||
file_extensions:
|
||||
- ".cls"
|
||||
- ".trigger"
|
||||
regex: /\/\/\s*FIXME|\/\*\s*FIXME/gi
|
||||
severity: 4
|
||||
tags:
|
||||
- Documentation
|
||||
- TechnicalDebt
|
||||
- sf-skills
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARCHITECTURE (TAF Pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
- name: DirectTriggerLogic
|
||||
description: "Logic directly in trigger body (should use handler)"
|
||||
message: "Move business logic to a handler class following Trigger Actions Framework pattern"
|
||||
file_extensions:
|
||||
- ".trigger"
|
||||
# Matches SOQL/DML directly in trigger body
|
||||
regex: /trigger\s+\w+\s+on\s+\w+[^}]*(\[\s*SELECT|\binsert\b|\bupdate\b|\bdelete\b|\bupsert\b)/gis
|
||||
severity: 3
|
||||
tags:
|
||||
- Architecture
|
||||
- TriggerPattern
|
||||
- sf-skills
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# FLOW PATTERNS (.flow-meta.xml)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- name: FlowOldApiVersion
|
||||
description: "Flow using API version older than 62.0"
|
||||
message: "Update Flow to API version 62.0 or later for latest features"
|
||||
file_extensions:
|
||||
- ".flow-meta.xml"
|
||||
regex: /<apiVersion>(4[0-9]|5[0-9]|6[01])\.[0-9]<\/apiVersion>/g
|
||||
severity: 3
|
||||
tags:
|
||||
- BestPractices
|
||||
- Flow
|
||||
- sf-skills
|
||||
|
||||
- name: FlowMissingDescription
|
||||
description: "Flow element without description"
|
||||
message: "Add descriptions to Flow elements for maintainability"
|
||||
file_extensions:
|
||||
- ".flow-meta.xml"
|
||||
# Matches elements without <description> child
|
||||
regex: /<(recordCreates|recordUpdates|recordDeletes|recordLookups|decisions|assignments|screens|subflows)[^>]*>(?:(?!<description>).)*?<\/\1>/gis
|
||||
severity: 4
|
||||
tags:
|
||||
- Documentation
|
||||
- Flow
|
||||
- sf-skills
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LWC PATTERNS (.js)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- name: LwcInnerHtml
|
||||
description: "Using innerHTML in LWC (XSS risk)"
|
||||
message: "Avoid innerHTML - use lwc:dom='manual' with proper sanitization or template bindings"
|
||||
file_extensions:
|
||||
- ".js"
|
||||
regex: /\.innerHTML\s*=/gi
|
||||
severity: 2
|
||||
tags:
|
||||
- Security
|
||||
- XSS
|
||||
- LWC
|
||||
- sf-skills
|
||||
|
||||
- name: LwcDocumentQuery
|
||||
description: "Using document.querySelector in LWC"
|
||||
message: "Use this.template.querySelector() instead of document.querySelector() in LWC"
|
||||
file_extensions:
|
||||
- ".js"
|
||||
regex: /document\.(querySelector|querySelectorAll|getElementById)/gi
|
||||
severity: 3
|
||||
tags:
|
||||
- BestPractices
|
||||
- LWC
|
||||
- sf-skills
|
||||
|
||||
- name: LwcConsoleLog
|
||||
description: "Console.log in production LWC code"
|
||||
message: "Remove console.log statements or use a logging utility for production code"
|
||||
file_extensions:
|
||||
- ".js"
|
||||
regex: /console\.(log|warn|error|info|debug)\s*\(/gi
|
||||
severity: 4
|
||||
tags:
|
||||
- CleanCode
|
||||
- LWC
|
||||
- sf-skills
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# METADATA PATTERNS (-meta.xml)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
- name: OldApiVersionMeta
|
||||
description: "Metadata using API version older than 62.0"
|
||||
message: "Update metadata to API version 62.0 or later"
|
||||
file_extensions:
|
||||
- ".cls-meta.xml"
|
||||
- ".trigger-meta.xml"
|
||||
- ".component-meta.xml"
|
||||
- ".page-meta.xml"
|
||||
regex: /<apiVersion>(4[0-9]|5[0-9]|6[01])\.[0-9]<\/apiVersion>/g
|
||||
severity: 4
|
||||
tags:
|
||||
- BestPractices
|
||||
- Metadata
|
||||
- sf-skills
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dependency Checker for Salesforce Code Analyzer V5.
|
||||
|
||||
Detects availability of runtime dependencies required by different CA engines:
|
||||
- JDK 11+ (PMD, CPD, SFGE)
|
||||
- Node.js (ESLint, RetireJS)
|
||||
- Python 3.10+ (Flow Scanner)
|
||||
- sf CLI with code-analyzer plugin
|
||||
|
||||
Provides graceful degradation information when dependencies are missing.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyStatus:
|
||||
"""Status of a single dependency."""
|
||||
name: str
|
||||
available: bool
|
||||
version: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
install_hint: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineAvailability:
|
||||
"""Availability status for CA engines."""
|
||||
engine: str
|
||||
available: bool
|
||||
reason: Optional[str] = None
|
||||
dependencies: List[str] = None
|
||||
|
||||
|
||||
class DependencyChecker:
|
||||
"""
|
||||
Checks and caches dependency availability for Code Analyzer engines.
|
||||
|
||||
Usage:
|
||||
checker = DependencyChecker()
|
||||
|
||||
# Check specific dependency
|
||||
java_status = checker.check_java()
|
||||
|
||||
# Get all engine availability
|
||||
engines = checker.get_engine_availability()
|
||||
|
||||
# Get user-friendly message
|
||||
message = checker.get_availability_message()
|
||||
"""
|
||||
|
||||
# Engine -> Required dependencies mapping
|
||||
ENGINE_DEPENDENCIES = {
|
||||
"pmd": ["java", "sf_cli"],
|
||||
"cpd": ["java", "sf_cli"],
|
||||
"sfge": ["java", "sf_cli"],
|
||||
"eslint": ["node", "sf_cli"],
|
||||
"retire-js": ["node", "sf_cli"],
|
||||
"flow": ["python", "sf_cli"],
|
||||
"regex": ["sf_cli"], # Regex only needs sf CLI
|
||||
}
|
||||
|
||||
# Install hints for each dependency
|
||||
INSTALL_HINTS = {
|
||||
"java": {
|
||||
"darwin": "brew install openjdk@11",
|
||||
"linux": "sudo apt install openjdk-11-jdk",
|
||||
"win32": "Download from https://adoptium.net/",
|
||||
},
|
||||
"node": {
|
||||
"darwin": "brew install node",
|
||||
"linux": "sudo apt install nodejs npm",
|
||||
"win32": "Download from https://nodejs.org/",
|
||||
},
|
||||
"python": {
|
||||
"darwin": "brew install python@3.10",
|
||||
"linux": "sudo apt install python3.10",
|
||||
"win32": "Download from https://python.org/",
|
||||
},
|
||||
"sf_cli": {
|
||||
"all": "npm install -g @salesforce/cli && sf plugins install @salesforce/sfdx-code-analyzer",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize dependency checker."""
|
||||
self._cache: Dict[str, DependencyStatus] = {}
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the dependency cache (useful for re-checking)."""
|
||||
self._cache.clear()
|
||||
|
||||
# Common Java installation paths to check as fallback
|
||||
JAVA_PATHS = [
|
||||
# Homebrew on Apple Silicon
|
||||
"/opt/homebrew/opt/openjdk@11/bin/java",
|
||||
"/opt/homebrew/opt/openjdk@17/bin/java",
|
||||
"/opt/homebrew/opt/openjdk@21/bin/java",
|
||||
"/opt/homebrew/opt/openjdk/bin/java",
|
||||
# Homebrew on Intel Mac
|
||||
"/usr/local/opt/openjdk@11/bin/java",
|
||||
"/usr/local/opt/openjdk@17/bin/java",
|
||||
"/usr/local/opt/openjdk@21/bin/java",
|
||||
"/usr/local/opt/openjdk/bin/java",
|
||||
# Standard macOS/Linux locations
|
||||
"/usr/bin/java",
|
||||
"/usr/local/bin/java",
|
||||
]
|
||||
|
||||
def _try_java_at_path(self, java_path: str) -> Optional[DependencyStatus]:
|
||||
"""
|
||||
Try to get Java version from a specific path.
|
||||
|
||||
Returns:
|
||||
DependencyStatus if valid Java found, None otherwise
|
||||
"""
|
||||
import os
|
||||
if not os.path.exists(java_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[java_path, "-version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Java outputs version to stderr
|
||||
output = result.stderr.lower()
|
||||
|
||||
# Parse version (e.g., "openjdk version \"11.0.2\"" or "java version \"17.0.1\"")
|
||||
version_match = re.search(r'version\s*["\']?(\d+)(?:\.(\d+))?', output)
|
||||
|
||||
if version_match:
|
||||
major = int(version_match.group(1))
|
||||
version_str = version_match.group(0)
|
||||
|
||||
if major >= 11:
|
||||
return DependencyStatus(
|
||||
name="Java (JDK 11+)",
|
||||
available=True,
|
||||
version=version_str,
|
||||
path=java_path,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def check_java(self) -> DependencyStatus:
|
||||
"""
|
||||
Check if JDK 11+ is available.
|
||||
|
||||
Checks multiple locations including Homebrew paths to handle
|
||||
wrapper scripts that may intercept the default java command.
|
||||
|
||||
Returns:
|
||||
DependencyStatus with version info if available
|
||||
"""
|
||||
if "java" in self._cache:
|
||||
return self._cache["java"]
|
||||
|
||||
# First, try JAVA_HOME if set
|
||||
import os
|
||||
java_home = os.environ.get("JAVA_HOME")
|
||||
if java_home:
|
||||
java_path = os.path.join(java_home, "bin", "java")
|
||||
status = self._try_java_at_path(java_path)
|
||||
if status:
|
||||
self._cache["java"] = status
|
||||
return status
|
||||
|
||||
# Try default PATH java
|
||||
java_path = shutil.which("java")
|
||||
if java_path:
|
||||
status = self._try_java_at_path(java_path)
|
||||
if status:
|
||||
self._cache["java"] = status
|
||||
return status
|
||||
|
||||
# Try common installation paths (Homebrew, etc.)
|
||||
for fallback_path in self.JAVA_PATHS:
|
||||
status = self._try_java_at_path(fallback_path)
|
||||
if status:
|
||||
self._cache["java"] = status
|
||||
return status
|
||||
|
||||
# No valid Java found
|
||||
status = DependencyStatus(
|
||||
name="Java (JDK 11+)",
|
||||
available=False,
|
||||
error="JDK 11+ not found in PATH or common locations",
|
||||
install_hint=self._get_install_hint("java"),
|
||||
)
|
||||
self._cache["java"] = status
|
||||
return status
|
||||
|
||||
def check_node(self) -> DependencyStatus:
|
||||
"""
|
||||
Check if Node.js is available.
|
||||
|
||||
Returns:
|
||||
DependencyStatus with version info if available
|
||||
"""
|
||||
if "node" in self._cache:
|
||||
return self._cache["node"]
|
||||
|
||||
try:
|
||||
node_path = shutil.which("node")
|
||||
if not node_path:
|
||||
status = DependencyStatus(
|
||||
name="Node.js",
|
||||
available=False,
|
||||
error="node command not found in PATH",
|
||||
install_hint=self._get_install_hint("node"),
|
||||
)
|
||||
self._cache["node"] = status
|
||||
return status
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()
|
||||
status = DependencyStatus(
|
||||
name="Node.js",
|
||||
available=True,
|
||||
version=version,
|
||||
path=node_path,
|
||||
)
|
||||
else:
|
||||
status = DependencyStatus(
|
||||
name="Node.js",
|
||||
available=False,
|
||||
error=result.stderr.strip() or "Unknown error",
|
||||
install_hint=self._get_install_hint("node"),
|
||||
)
|
||||
|
||||
self._cache["node"] = status
|
||||
return status
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
status = DependencyStatus(
|
||||
name="Node.js",
|
||||
available=False,
|
||||
error="node --version timed out",
|
||||
install_hint=self._get_install_hint("node"),
|
||||
)
|
||||
self._cache["node"] = status
|
||||
return status
|
||||
except Exception as e:
|
||||
status = DependencyStatus(
|
||||
name="Node.js",
|
||||
available=False,
|
||||
error=str(e),
|
||||
install_hint=self._get_install_hint("node"),
|
||||
)
|
||||
self._cache["node"] = status
|
||||
return status
|
||||
|
||||
def check_python(self) -> DependencyStatus:
|
||||
"""
|
||||
Check if Python 3.10+ is available.
|
||||
|
||||
Returns:
|
||||
DependencyStatus with version info if available
|
||||
"""
|
||||
if "python" in self._cache:
|
||||
return self._cache["python"]
|
||||
|
||||
# If we're running, Python is available - check version
|
||||
major = sys.version_info.major
|
||||
minor = sys.version_info.minor
|
||||
version = f"{major}.{minor}.{sys.version_info.micro}"
|
||||
|
||||
if major >= 3 and minor >= 10:
|
||||
status = DependencyStatus(
|
||||
name="Python 3.10+",
|
||||
available=True,
|
||||
version=version,
|
||||
path=sys.executable,
|
||||
)
|
||||
else:
|
||||
status = DependencyStatus(
|
||||
name="Python 3.10+",
|
||||
available=False,
|
||||
version=version,
|
||||
path=sys.executable,
|
||||
error=f"Python {major}.{minor} found, but 3.10+ required for Flow Scanner",
|
||||
install_hint=self._get_install_hint("python"),
|
||||
)
|
||||
|
||||
self._cache["python"] = status
|
||||
return status
|
||||
|
||||
def check_sf_cli(self) -> DependencyStatus:
|
||||
"""
|
||||
Check if Salesforce CLI with code-analyzer plugin is available.
|
||||
|
||||
Returns:
|
||||
DependencyStatus with version info if available
|
||||
"""
|
||||
if "sf_cli" in self._cache:
|
||||
return self._cache["sf_cli"]
|
||||
|
||||
try:
|
||||
sf_path = shutil.which("sf")
|
||||
if not sf_path:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI",
|
||||
available=False,
|
||||
error="sf command not found in PATH",
|
||||
install_hint=self._get_install_hint("sf_cli"),
|
||||
)
|
||||
self._cache["sf_cli"] = status
|
||||
return status
|
||||
|
||||
# Check sf version
|
||||
result = subprocess.run(
|
||||
["sf", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI",
|
||||
available=False,
|
||||
error="sf --version failed",
|
||||
install_hint=self._get_install_hint("sf_cli"),
|
||||
)
|
||||
self._cache["sf_cli"] = status
|
||||
return status
|
||||
|
||||
sf_version = result.stdout.strip().split("\n")[0]
|
||||
|
||||
# Check if code-analyzer plugin is installed
|
||||
plugin_result = subprocess.run(
|
||||
["sf", "plugins"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
has_ca_plugin = "code-analyzer" in plugin_result.stdout.lower() or \
|
||||
"sfdx-scanner" in plugin_result.stdout.lower()
|
||||
|
||||
if has_ca_plugin:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI + Code Analyzer",
|
||||
available=True,
|
||||
version=sf_version,
|
||||
path=sf_path,
|
||||
)
|
||||
else:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI + Code Analyzer",
|
||||
available=False,
|
||||
version=sf_version,
|
||||
path=sf_path,
|
||||
error="Code Analyzer plugin not installed",
|
||||
install_hint="sf plugins install @salesforce/sfdx-code-analyzer",
|
||||
)
|
||||
|
||||
self._cache["sf_cli"] = status
|
||||
return status
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI",
|
||||
available=False,
|
||||
error="sf command timed out",
|
||||
install_hint=self._get_install_hint("sf_cli"),
|
||||
)
|
||||
self._cache["sf_cli"] = status
|
||||
return status
|
||||
except Exception as e:
|
||||
status = DependencyStatus(
|
||||
name="Salesforce CLI",
|
||||
available=False,
|
||||
error=str(e),
|
||||
install_hint=self._get_install_hint("sf_cli"),
|
||||
)
|
||||
self._cache["sf_cli"] = status
|
||||
return status
|
||||
|
||||
def check_all(self) -> Dict[str, DependencyStatus]:
|
||||
"""
|
||||
Check all dependencies.
|
||||
|
||||
Returns:
|
||||
Dict mapping dependency name to status
|
||||
"""
|
||||
return {
|
||||
"java": self.check_java(),
|
||||
"node": self.check_node(),
|
||||
"python": self.check_python(),
|
||||
"sf_cli": self.check_sf_cli(),
|
||||
}
|
||||
|
||||
def get_engine_availability(self) -> Dict[str, EngineAvailability]:
|
||||
"""
|
||||
Get availability status for each Code Analyzer engine.
|
||||
|
||||
Returns:
|
||||
Dict mapping engine name to availability status
|
||||
"""
|
||||
deps = self.check_all()
|
||||
engines = {}
|
||||
|
||||
for engine, required_deps in self.ENGINE_DEPENDENCIES.items():
|
||||
missing = []
|
||||
for dep in required_deps:
|
||||
if not deps[dep].available:
|
||||
missing.append(deps[dep].name)
|
||||
|
||||
if missing:
|
||||
engines[engine] = EngineAvailability(
|
||||
engine=engine,
|
||||
available=False,
|
||||
reason=f"Missing: {', '.join(missing)}",
|
||||
dependencies=required_deps,
|
||||
)
|
||||
else:
|
||||
engines[engine] = EngineAvailability(
|
||||
engine=engine,
|
||||
available=True,
|
||||
dependencies=required_deps,
|
||||
)
|
||||
|
||||
return engines
|
||||
|
||||
def get_available_engines(self) -> List[str]:
|
||||
"""Get list of available engine names."""
|
||||
engines = self.get_engine_availability()
|
||||
return [name for name, status in engines.items() if status.available]
|
||||
|
||||
def get_unavailable_engines(self) -> List[Tuple[str, str]]:
|
||||
"""Get list of unavailable engines with reasons."""
|
||||
engines = self.get_engine_availability()
|
||||
return [
|
||||
(name, status.reason)
|
||||
for name, status in engines.items()
|
||||
if not status.available
|
||||
]
|
||||
|
||||
def get_availability_message(self) -> str:
|
||||
"""
|
||||
Get a formatted message about engine availability.
|
||||
|
||||
Returns:
|
||||
Human-readable status message
|
||||
"""
|
||||
engines = self.get_engine_availability()
|
||||
available = [e for e, s in engines.items() if s.available]
|
||||
unavailable = [(e, s.reason) for e, s in engines.items() if not s.available]
|
||||
|
||||
lines = []
|
||||
|
||||
if available:
|
||||
lines.append(f"Available engines: {', '.join(available)}")
|
||||
|
||||
if unavailable:
|
||||
lines.append("Unavailable engines:")
|
||||
for engine, reason in unavailable:
|
||||
lines.append(f" - {engine}: {reason}")
|
||||
|
||||
# Add install hints
|
||||
deps = self.check_all()
|
||||
missing_deps = [d for d, s in deps.items() if not s.available]
|
||||
if missing_deps:
|
||||
lines.append("")
|
||||
lines.append("To enable more engines, install:")
|
||||
for dep in missing_deps:
|
||||
status = deps[dep]
|
||||
if status.install_hint:
|
||||
lines.append(f" {status.name}: {status.install_hint}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_install_hint(self, dependency: str) -> str:
|
||||
"""Get platform-specific install hint."""
|
||||
hints = self.INSTALL_HINTS.get(dependency, {})
|
||||
|
||||
if "all" in hints:
|
||||
return hints["all"]
|
||||
|
||||
import platform
|
||||
system = platform.system().lower()
|
||||
|
||||
if system == "darwin":
|
||||
return hints.get("darwin", "")
|
||||
elif system == "linux":
|
||||
return hints.get("linux", "")
|
||||
elif system == "windows":
|
||||
return hints.get("win32", "")
|
||||
else:
|
||||
return hints.get("linux", "") # Default to linux
|
||||
|
||||
|
||||
# Convenience function
|
||||
def check_dependencies() -> Dict[str, bool]:
|
||||
"""
|
||||
Quick check of all dependencies.
|
||||
|
||||
Returns:
|
||||
Dict mapping dependency name to availability boolean
|
||||
"""
|
||||
checker = DependencyChecker()
|
||||
deps = checker.check_all()
|
||||
return {name: status.available for name, status in deps.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run dependency check when executed directly
|
||||
checker = DependencyChecker()
|
||||
print(checker.get_availability_message())
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Output Formatter - Terminal-friendly output for validation results.
|
||||
|
||||
Produces formatted output combining:
|
||||
- Custom sf-skills scoring (150-point for Apex, etc.)
|
||||
- Code Analyzer V5 findings
|
||||
- Engine availability status
|
||||
- Issue list with severity icons
|
||||
|
||||
Usage:
|
||||
output = format_validation_output(
|
||||
file_name="AccountService.cls",
|
||||
merged_score=merged,
|
||||
custom_issues=custom_issues,
|
||||
ca_violations=ca_violations,
|
||||
)
|
||||
print(output)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Severity icons for terminal display
|
||||
SEVERITY_ICONS = {
|
||||
"CRITICAL": "",
|
||||
"HIGH": "",
|
||||
"MODERATE": "",
|
||||
"WARNING": "",
|
||||
"LOW": "",
|
||||
"INFO": "",
|
||||
}
|
||||
|
||||
# Category status icons
|
||||
STATUS_ICONS = {
|
||||
"pass": "",
|
||||
"partial": "",
|
||||
"fail": "",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormattedIssue:
|
||||
"""A formatted issue for display."""
|
||||
severity: str
|
||||
icon: str
|
||||
source: str
|
||||
line: int
|
||||
message: str
|
||||
fix: Optional[str] = None
|
||||
rule: Optional[str] = None
|
||||
|
||||
|
||||
def format_validation_output(
|
||||
file_name: str,
|
||||
final_score: int,
|
||||
final_max: int,
|
||||
rating: str,
|
||||
rating_stars: int,
|
||||
category_scores: Dict[str, tuple], # {category: (score, max)}
|
||||
engines_used: List[str],
|
||||
engines_unavailable: List[str],
|
||||
issues: List[FormattedIssue],
|
||||
scan_time_ms: int = 0,
|
||||
) -> str:
|
||||
"""
|
||||
Format complete validation output for terminal display.
|
||||
|
||||
Args:
|
||||
file_name: Name of file being validated
|
||||
final_score: Final combined score
|
||||
final_max: Maximum possible score
|
||||
rating: Rating label (e.g., "Very Good")
|
||||
rating_stars: Number of stars (1-5)
|
||||
category_scores: Dict of category -> (score, max)
|
||||
engines_used: List of CA engines that ran
|
||||
engines_unavailable: List of unavailable engines
|
||||
issues: List of FormattedIssue objects
|
||||
scan_time_ms: Scan duration in milliseconds
|
||||
|
||||
Returns:
|
||||
Formatted string for terminal output
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append("")
|
||||
lines.append(f" Apex Validation: {file_name}")
|
||||
lines.append("" * 60)
|
||||
|
||||
# Score with rating
|
||||
stars = "" * rating_stars + "" * (5 - rating_stars)
|
||||
lines.append(f" Score: {final_score}/{final_max} {stars} {rating}")
|
||||
|
||||
# Category breakdown
|
||||
if category_scores:
|
||||
lines.append("")
|
||||
lines.append(" Category Breakdown:")
|
||||
for category, (score, max_score) in category_scores.items():
|
||||
if max_score > 0:
|
||||
if score == max_score:
|
||||
icon = STATUS_ICONS["pass"]
|
||||
elif score >= max_score * 0.7:
|
||||
icon = STATUS_ICONS["partial"]
|
||||
else:
|
||||
icon = STATUS_ICONS["fail"]
|
||||
|
||||
diff = ""
|
||||
if score < max_score:
|
||||
diff = f" (-{max_score - score})"
|
||||
|
||||
# Format category name nicely
|
||||
display_name = category.replace("_", " ").title()
|
||||
lines.append(f" {icon} {display_name}: {score}/{max_score}{diff}")
|
||||
|
||||
# Code Analyzer status
|
||||
lines.append("")
|
||||
if engines_used:
|
||||
lines.append(f" Code Analyzer Engines: {', '.join(engines_used)}")
|
||||
else:
|
||||
lines.append(" Code Analyzer: Not available")
|
||||
|
||||
if engines_unavailable:
|
||||
lines.append(f" Unavailable: {', '.join(engines_unavailable)}")
|
||||
|
||||
if scan_time_ms > 0:
|
||||
lines.append(f" Scan time: {scan_time_ms}ms")
|
||||
|
||||
# Issues
|
||||
if issues:
|
||||
lines.append("")
|
||||
lines.append(f" Issues Found ({len(issues)}):")
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MODERATE": 2, "WARNING": 3, "LOW": 4, "INFO": 5}
|
||||
sorted_issues = sorted(issues, key=lambda x: severity_order.get(x.severity, 5))
|
||||
|
||||
# Display up to 15 issues
|
||||
for issue in sorted_issues[:15]:
|
||||
source_tag = f"[{issue.source}]" if issue.source else ""
|
||||
line_tag = f"L{issue.line}" if issue.line else ""
|
||||
|
||||
# Truncate message if too long
|
||||
message = issue.message
|
||||
if len(message) > 70:
|
||||
message = message[:67] + "..."
|
||||
|
||||
lines.append(f" {issue.icon} {issue.severity} {source_tag} {line_tag}: {message}")
|
||||
|
||||
if issue.fix:
|
||||
fix = issue.fix
|
||||
if len(fix) > 60:
|
||||
fix = fix[:57] + "..."
|
||||
lines.append(f" Fix: {fix}")
|
||||
|
||||
if len(issues) > 15:
|
||||
lines.append(f" ... and {len(issues) - 15} more issues")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append(" No issues found!")
|
||||
|
||||
# Footer
|
||||
lines.append("" * 60)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_score_summary(
|
||||
final_score: int,
|
||||
final_max: int,
|
||||
rating: str,
|
||||
rating_stars: int,
|
||||
) -> str:
|
||||
"""Format just the score line."""
|
||||
stars = "" * rating_stars + "" * (5 - rating_stars)
|
||||
return f" Score: {final_score}/{final_max} {stars} {rating}"
|
||||
|
||||
|
||||
def format_issues_list(
|
||||
issues: List[FormattedIssue],
|
||||
max_issues: int = 15,
|
||||
) -> str:
|
||||
"""Format just the issues list."""
|
||||
if not issues:
|
||||
return " No issues found!"
|
||||
|
||||
lines = [f" Issues Found ({len(issues)}):"]
|
||||
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MODERATE": 2, "WARNING": 3, "LOW": 4, "INFO": 5}
|
||||
sorted_issues = sorted(issues, key=lambda x: severity_order.get(x.severity, 5))
|
||||
|
||||
for issue in sorted_issues[:max_issues]:
|
||||
source_tag = f"[{issue.source}]" if issue.source else ""
|
||||
line_tag = f"L{issue.line}" if issue.line else ""
|
||||
message = issue.message[:70] + "..." if len(issue.message) > 70 else issue.message
|
||||
|
||||
lines.append(f" {issue.icon} {issue.severity} {source_tag} {line_tag}: {message}")
|
||||
|
||||
if len(issues) > max_issues:
|
||||
lines.append(f" ... and {len(issues) - max_issues} more issues")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_engine_status(
|
||||
engines_used: List[str],
|
||||
engines_unavailable: List[str],
|
||||
) -> str:
|
||||
"""Format engine availability status."""
|
||||
lines = []
|
||||
|
||||
if engines_used:
|
||||
lines.append(f" Code Analyzer Engines: {', '.join(engines_used)}")
|
||||
else:
|
||||
lines.append(" Code Analyzer: Not available")
|
||||
|
||||
if engines_unavailable:
|
||||
lines.append(f" Unavailable: {', '.join(engines_unavailable)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_issue(
|
||||
severity: str,
|
||||
source: str,
|
||||
message: str,
|
||||
line: int = 0,
|
||||
fix: Optional[str] = None,
|
||||
rule: Optional[str] = None,
|
||||
) -> FormattedIssue:
|
||||
"""Create a FormattedIssue with proper icon."""
|
||||
icon = SEVERITY_ICONS.get(severity.upper(), "")
|
||||
return FormattedIssue(
|
||||
severity=severity.upper(),
|
||||
icon=icon,
|
||||
source=source,
|
||||
line=line,
|
||||
message=message,
|
||||
fix=fix,
|
||||
rule=rule,
|
||||
)
|
||||
|
||||
|
||||
def merge_issues(
|
||||
custom_issues: List[Dict[str, Any]],
|
||||
ca_violations: List[Dict[str, Any]],
|
||||
) -> List[FormattedIssue]:
|
||||
"""
|
||||
Merge custom issues and CA violations into formatted issues list.
|
||||
|
||||
Args:
|
||||
custom_issues: Issues from custom sf-skills validator
|
||||
ca_violations: Violations from Code Analyzer
|
||||
|
||||
Returns:
|
||||
Combined list of FormattedIssue objects
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# Add custom issues
|
||||
for issue in custom_issues:
|
||||
issues.append(create_issue(
|
||||
severity=issue.get("severity", "INFO"),
|
||||
source="sf-skills",
|
||||
message=issue.get("message", ""),
|
||||
line=issue.get("line", 0),
|
||||
fix=issue.get("fix"),
|
||||
rule=issue.get("rule"),
|
||||
))
|
||||
|
||||
# Add CA violations
|
||||
for violation in ca_violations:
|
||||
engine = violation.get("engine", "CA")
|
||||
issues.append(create_issue(
|
||||
severity=violation.get("severity_label", "INFO"),
|
||||
source=f"CA:{engine}",
|
||||
message=violation.get("message", ""),
|
||||
line=violation.get("line", 0),
|
||||
rule=violation.get("rule"),
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def format_compact_summary(
|
||||
file_name: str,
|
||||
final_score: int,
|
||||
final_max: int,
|
||||
issue_count: int,
|
||||
) -> str:
|
||||
"""Format a compact one-line summary."""
|
||||
status = "" if issue_count == 0 else ""
|
||||
return f"{status} {file_name}: {final_score}/{final_max} ({issue_count} issues)"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo output
|
||||
from score_merger import MergedScore
|
||||
|
||||
category_scores = {
|
||||
"bulkification": (25, 25),
|
||||
"security": (20, 25),
|
||||
"testing": (25, 25),
|
||||
"architecture": (18, 20),
|
||||
"clean_code": (18, 20),
|
||||
"error_handling": (15, 15),
|
||||
"performance": (10, 10),
|
||||
"documentation": (7, 10),
|
||||
}
|
||||
|
||||
issues = [
|
||||
create_issue("CRITICAL", "CA:pmd", "SOQL query inside loop", 25, "Move query outside loop"),
|
||||
create_issue("HIGH", "CA:pmd", "Empty catch block", 40, "Log or handle exception"),
|
||||
create_issue("MODERATE", "sf-skills", "Public method missing ApexDoc", 12),
|
||||
create_issue("LOW", "CA:regex", "Trailing whitespace", 1),
|
||||
]
|
||||
|
||||
output = format_validation_output(
|
||||
file_name="AccountService.cls",
|
||||
final_score=138,
|
||||
final_max=150,
|
||||
rating="Very Good",
|
||||
rating_stars=4,
|
||||
category_scores=category_scores,
|
||||
engines_used=["pmd", "regex", "sfge"],
|
||||
engines_unavailable=["eslint"],
|
||||
issues=issues,
|
||||
scan_time_ms=1250,
|
||||
)
|
||||
|
||||
print(output)
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Code Analyzer Output Parser - JSON result normalization and filtering.
|
||||
|
||||
Provides utilities for:
|
||||
- Parsing raw Code Analyzer JSON output
|
||||
- Normalizing violations into a consistent format
|
||||
- Filtering violations by severity, engine, or tags
|
||||
- Grouping violations by file, rule, or category
|
||||
|
||||
Usage:
|
||||
# Parse raw output
|
||||
violations = parse_ca_output(raw_json)
|
||||
|
||||
# Filter by severity
|
||||
critical = filter_by_severity(violations, max_severity=2)
|
||||
|
||||
# Group by file
|
||||
by_file = group_by_file(violations)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
# Severity labels mapping
|
||||
SEVERITY_LABELS = {
|
||||
1: "CRITICAL",
|
||||
2: "HIGH",
|
||||
3: "MODERATE",
|
||||
4: "LOW",
|
||||
5: "INFO",
|
||||
}
|
||||
|
||||
# Reverse mapping
|
||||
SEVERITY_VALUES = {v: k for k, v in SEVERITY_LABELS.items()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedViolation:
|
||||
"""Normalized violation with consistent fields."""
|
||||
rule: str
|
||||
engine: str
|
||||
severity: int
|
||||
severity_label: str
|
||||
message: str
|
||||
file: str
|
||||
line: int
|
||||
end_line: int
|
||||
column: int
|
||||
end_column: int
|
||||
tags: List[str]
|
||||
resources: List[str]
|
||||
raw: Dict[str, Any]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"rule": self.rule,
|
||||
"engine": self.engine,
|
||||
"severity": self.severity,
|
||||
"severity_label": self.severity_label,
|
||||
"message": self.message,
|
||||
"file": self.file,
|
||||
"line": self.line,
|
||||
"end_line": self.end_line,
|
||||
"column": self.column,
|
||||
"end_column": self.end_column,
|
||||
"tags": self.tags,
|
||||
"resources": self.resources,
|
||||
}
|
||||
|
||||
|
||||
def normalize_violation(raw_violation: Dict[str, Any]) -> NormalizedViolation:
|
||||
"""
|
||||
Normalize a single violation from CA output.
|
||||
|
||||
Args:
|
||||
raw_violation: Raw violation dict from CA JSON output
|
||||
|
||||
Returns:
|
||||
NormalizedViolation with consistent fields
|
||||
"""
|
||||
# Get primary location
|
||||
locations = raw_violation.get("locations", [])
|
||||
primary_idx = raw_violation.get("primaryLocationIndex", 0)
|
||||
|
||||
if locations and primary_idx < len(locations):
|
||||
primary_loc = locations[primary_idx]
|
||||
else:
|
||||
primary_loc = {}
|
||||
|
||||
# Get severity
|
||||
severity = raw_violation.get("severity", 5)
|
||||
severity_label = SEVERITY_LABELS.get(severity, "UNKNOWN")
|
||||
|
||||
return NormalizedViolation(
|
||||
rule=raw_violation.get("rule", ""),
|
||||
engine=raw_violation.get("engine", "unknown"),
|
||||
severity=severity,
|
||||
severity_label=severity_label,
|
||||
message=raw_violation.get("message", ""),
|
||||
file=primary_loc.get("file", ""),
|
||||
line=primary_loc.get("startLine", 0),
|
||||
end_line=primary_loc.get("endLine", 0),
|
||||
column=primary_loc.get("startColumn", 0),
|
||||
end_column=primary_loc.get("endColumn", 0),
|
||||
tags=raw_violation.get("tags", []),
|
||||
resources=raw_violation.get("resources", []),
|
||||
raw=raw_violation,
|
||||
)
|
||||
|
||||
|
||||
def parse_ca_output(raw_output: Dict[str, Any]) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Parse Code Analyzer JSON output into normalized violations.
|
||||
|
||||
Args:
|
||||
raw_output: Full JSON output from Code Analyzer
|
||||
|
||||
Returns:
|
||||
List of NormalizedViolation objects
|
||||
"""
|
||||
violations = []
|
||||
|
||||
for raw_violation in raw_output.get("violations", []):
|
||||
# Skip engine instantiation errors
|
||||
if raw_violation.get("rule") == "UninstantiableEngineError":
|
||||
continue
|
||||
|
||||
violations.append(normalize_violation(raw_violation))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def filter_by_severity(
|
||||
violations: List[NormalizedViolation],
|
||||
min_severity: int = 1,
|
||||
max_severity: int = 5,
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Filter violations by severity range.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
min_severity: Minimum severity (1=Critical, 5=Info)
|
||||
max_severity: Maximum severity
|
||||
|
||||
Returns:
|
||||
Filtered list of violations
|
||||
"""
|
||||
return [
|
||||
v for v in violations
|
||||
if min_severity <= v.severity <= max_severity
|
||||
]
|
||||
|
||||
|
||||
def filter_by_engine(
|
||||
violations: List[NormalizedViolation],
|
||||
engines: List[str],
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Filter violations by engine name.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
engines: List of engine names to include
|
||||
|
||||
Returns:
|
||||
Filtered list of violations
|
||||
"""
|
||||
engine_set = set(e.lower() for e in engines)
|
||||
return [v for v in violations if v.engine.lower() in engine_set]
|
||||
|
||||
|
||||
def filter_by_tags(
|
||||
violations: List[NormalizedViolation],
|
||||
tags: List[str],
|
||||
match_all: bool = False,
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Filter violations by tags.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
tags: List of tags to match
|
||||
match_all: If True, violation must have all tags. If False, any tag.
|
||||
|
||||
Returns:
|
||||
Filtered list of violations
|
||||
"""
|
||||
tag_set = set(t.lower() for t in tags)
|
||||
|
||||
def matches(v: NormalizedViolation) -> bool:
|
||||
v_tags = set(t.lower() for t in v.tags)
|
||||
if match_all:
|
||||
return tag_set.issubset(v_tags)
|
||||
else:
|
||||
return bool(tag_set & v_tags)
|
||||
|
||||
return [v for v in violations if matches(v)]
|
||||
|
||||
|
||||
def filter_by_rule(
|
||||
violations: List[NormalizedViolation],
|
||||
rules: List[str],
|
||||
exclude: bool = False,
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Filter violations by rule name.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
rules: List of rule names
|
||||
exclude: If True, exclude these rules. If False, include only these.
|
||||
|
||||
Returns:
|
||||
Filtered list of violations
|
||||
"""
|
||||
rule_set = set(r.lower() for r in rules)
|
||||
|
||||
if exclude:
|
||||
return [v for v in violations if v.rule.lower() not in rule_set]
|
||||
else:
|
||||
return [v for v in violations if v.rule.lower() in rule_set]
|
||||
|
||||
|
||||
def filter_custom(
|
||||
violations: List[NormalizedViolation],
|
||||
predicate: Callable[[NormalizedViolation], bool],
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Filter violations with custom predicate.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
predicate: Function that returns True for violations to keep
|
||||
|
||||
Returns:
|
||||
Filtered list of violations
|
||||
"""
|
||||
return [v for v in violations if predicate(v)]
|
||||
|
||||
|
||||
def group_by_file(
|
||||
violations: List[NormalizedViolation],
|
||||
) -> Dict[str, List[NormalizedViolation]]:
|
||||
"""
|
||||
Group violations by file path.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
|
||||
Returns:
|
||||
Dict mapping file path to list of violations
|
||||
"""
|
||||
grouped = defaultdict(list)
|
||||
for v in violations:
|
||||
grouped[v.file].append(v)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def group_by_rule(
|
||||
violations: List[NormalizedViolation],
|
||||
) -> Dict[str, List[NormalizedViolation]]:
|
||||
"""
|
||||
Group violations by rule name.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
|
||||
Returns:
|
||||
Dict mapping rule name to list of violations
|
||||
"""
|
||||
grouped = defaultdict(list)
|
||||
for v in violations:
|
||||
grouped[v.rule].append(v)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def group_by_engine(
|
||||
violations: List[NormalizedViolation],
|
||||
) -> Dict[str, List[NormalizedViolation]]:
|
||||
"""
|
||||
Group violations by engine.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
|
||||
Returns:
|
||||
Dict mapping engine name to list of violations
|
||||
"""
|
||||
grouped = defaultdict(list)
|
||||
for v in violations:
|
||||
grouped[v.engine].append(v)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def group_by_severity(
|
||||
violations: List[NormalizedViolation],
|
||||
) -> Dict[str, List[NormalizedViolation]]:
|
||||
"""
|
||||
Group violations by severity label.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
|
||||
Returns:
|
||||
Dict mapping severity label to list of violations
|
||||
"""
|
||||
grouped = defaultdict(list)
|
||||
for v in violations:
|
||||
grouped[v.severity_label].append(v)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def sort_violations(
|
||||
violations: List[NormalizedViolation],
|
||||
by: str = "severity",
|
||||
reverse: bool = False,
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Sort violations.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
by: Sort key - "severity", "line", "file", "rule", "engine"
|
||||
reverse: Reverse sort order
|
||||
|
||||
Returns:
|
||||
Sorted list of violations
|
||||
"""
|
||||
key_funcs = {
|
||||
"severity": lambda v: v.severity,
|
||||
"line": lambda v: v.line,
|
||||
"file": lambda v: v.file.lower(),
|
||||
"rule": lambda v: v.rule.lower(),
|
||||
"engine": lambda v: v.engine.lower(),
|
||||
}
|
||||
|
||||
key_func = key_funcs.get(by, key_funcs["severity"])
|
||||
return sorted(violations, key=key_func, reverse=reverse)
|
||||
|
||||
|
||||
def deduplicate_violations(
|
||||
violations: List[NormalizedViolation],
|
||||
by: str = "rule_line",
|
||||
) -> List[NormalizedViolation]:
|
||||
"""
|
||||
Deduplicate violations.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
by: Dedup key - "rule" (same rule), "rule_line" (same rule and line),
|
||||
"message" (same message)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of violations
|
||||
"""
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
for v in violations:
|
||||
if by == "rule":
|
||||
key = v.rule
|
||||
elif by == "rule_line":
|
||||
key = (v.rule, v.file, v.line)
|
||||
elif by == "message":
|
||||
key = v.message
|
||||
else:
|
||||
key = (v.rule, v.file, v.line)
|
||||
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(v)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_violation_counts(violations: List[NormalizedViolation]) -> Dict[str, int]:
|
||||
"""
|
||||
Get count of violations by severity.
|
||||
|
||||
Args:
|
||||
violations: List of violations
|
||||
|
||||
Returns:
|
||||
Dict with counts per severity and total
|
||||
"""
|
||||
counts = {
|
||||
"total": len(violations),
|
||||
"critical": 0,
|
||||
"high": 0,
|
||||
"moderate": 0,
|
||||
"low": 0,
|
||||
"info": 0,
|
||||
}
|
||||
|
||||
for v in violations:
|
||||
if v.severity == 1:
|
||||
counts["critical"] += 1
|
||||
elif v.severity == 2:
|
||||
counts["high"] += 1
|
||||
elif v.severity == 3:
|
||||
counts["moderate"] += 1
|
||||
elif v.severity == 4:
|
||||
counts["low"] += 1
|
||||
else:
|
||||
counts["info"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def to_dict_list(violations: List[NormalizedViolation]) -> List[Dict[str, Any]]:
|
||||
"""Convert list of violations to list of dicts."""
|
||||
return [v.to_dict() for v in violations]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo with sample data
|
||||
sample_output = {
|
||||
"violations": [
|
||||
{
|
||||
"rule": "AvoidSoqlInLoops",
|
||||
"engine": "pmd",
|
||||
"severity": 1,
|
||||
"message": "SOQL query found inside loop",
|
||||
"tags": ["Performance", "Apex"],
|
||||
"locations": [
|
||||
{"file": "AccountService.cls", "startLine": 25, "startColumn": 5}
|
||||
],
|
||||
"primaryLocationIndex": 0,
|
||||
},
|
||||
{
|
||||
"rule": "EmptyCatchBlock",
|
||||
"engine": "pmd",
|
||||
"severity": 2,
|
||||
"message": "Empty catch block",
|
||||
"tags": ["ErrorHandling", "Apex"],
|
||||
"locations": [
|
||||
{"file": "AccountService.cls", "startLine": 40, "startColumn": 9}
|
||||
],
|
||||
"primaryLocationIndex": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
violations = parse_ca_output(sample_output)
|
||||
print(f"Parsed {len(violations)} violations")
|
||||
|
||||
for v in violations:
|
||||
print(f" [{v.severity_label}] {v.rule}: {v.message}")
|
||||
|
||||
counts = get_violation_counts(violations)
|
||||
print(f"\nCounts: {counts}")
|
||||
@@ -0,0 +1,554 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Code Analyzer Scanner - Core wrapper for Salesforce Code Analyzer V5 CLI.
|
||||
|
||||
Provides a Python interface to the `sf code-analyzer run` command with:
|
||||
- Skill-type-aware rule selection
|
||||
- Graceful dependency handling
|
||||
- JSON output parsing
|
||||
- Configurable timeout and options
|
||||
|
||||
Usage:
|
||||
scanner = CodeAnalyzerScanner()
|
||||
result = scanner.scan("/path/to/file.cls", SkillType.APEX)
|
||||
|
||||
for violation in result.violations:
|
||||
print(f"{violation['severity_label']}: {violation['message']}")
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .dependency_checker import DependencyChecker
|
||||
|
||||
|
||||
class SkillType(Enum):
|
||||
"""Skill types for rule selection."""
|
||||
APEX = "apex"
|
||||
FLOW = "flow"
|
||||
LWC = "lwc"
|
||||
METADATA = "metadata"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Normalized scan result from Code Analyzer."""
|
||||
success: bool
|
||||
violations: List[Dict[str, Any]]
|
||||
engines_used: List[str]
|
||||
engines_unavailable: List[str]
|
||||
violation_counts: Dict[str, int]
|
||||
raw_output: Optional[Dict[str, Any]] = None
|
||||
error_message: Optional[str] = None
|
||||
scan_time_ms: int = 0
|
||||
|
||||
|
||||
class CodeAnalyzerScanner:
|
||||
"""
|
||||
Wrapper for Salesforce Code Analyzer V5.
|
||||
|
||||
Handles:
|
||||
- Rule selection based on skill type
|
||||
- Dependency checking and graceful degradation
|
||||
- CLI invocation with proper arguments
|
||||
- Output parsing and normalization
|
||||
|
||||
Usage:
|
||||
scanner = CodeAnalyzerScanner()
|
||||
|
||||
# Check what's available
|
||||
print(scanner.get_available_engines())
|
||||
|
||||
# Scan a file
|
||||
result = scanner.scan("/path/to/AccountService.cls", SkillType.APEX)
|
||||
|
||||
if result.success:
|
||||
for v in result.violations:
|
||||
print(f"[{v['severity_label']}] {v['rule']}: {v['message']}")
|
||||
"""
|
||||
|
||||
# Rule selectors by skill type
|
||||
# Format: engine:category or engine:tag:severity
|
||||
RULE_SELECTORS = {
|
||||
SkillType.APEX: [
|
||||
"pmd", # All PMD Apex rules
|
||||
"regex", # Regex patterns
|
||||
"cpd", # Copy-paste detection
|
||||
"sfge", # Graph engine (data flow)
|
||||
],
|
||||
SkillType.FLOW: [
|
||||
"flow", # Flow Scanner rules
|
||||
"regex", # Regex XML patterns
|
||||
],
|
||||
SkillType.LWC: [
|
||||
"eslint", # ESLint LWC rules
|
||||
"retire-js", # Vulnerability scanning
|
||||
],
|
||||
SkillType.METADATA: [
|
||||
"regex", # Regex patterns for XML
|
||||
],
|
||||
}
|
||||
|
||||
# File extensions by skill type
|
||||
FILE_EXTENSIONS = {
|
||||
SkillType.APEX: [".cls", ".trigger"],
|
||||
SkillType.FLOW: [".flow-meta.xml"],
|
||||
SkillType.LWC: [".js", ".html", ".css"],
|
||||
SkillType.METADATA: [".xml"],
|
||||
}
|
||||
|
||||
# Severity labels
|
||||
SEVERITY_LABELS = {
|
||||
1: "CRITICAL",
|
||||
2: "HIGH",
|
||||
3: "MODERATE",
|
||||
4: "LOW",
|
||||
5: "INFO",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Optional[str] = None,
|
||||
timeout_seconds: int = 120,
|
||||
):
|
||||
"""
|
||||
Initialize scanner.
|
||||
|
||||
Args:
|
||||
config_path: Path to code-analyzer.yml config file.
|
||||
If None, looks in shared/code-analyzer/config/
|
||||
timeout_seconds: Maximum time for scan (default 120s)
|
||||
"""
|
||||
self.config_path = config_path or self._find_config()
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._dep_checker = DependencyChecker()
|
||||
self._engine_availability = None
|
||||
self._java_env = self._get_java_env()
|
||||
|
||||
def _get_java_env(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get environment variables needed for Java.
|
||||
|
||||
If Java is found at a non-standard location (e.g., Homebrew),
|
||||
returns env vars to help sf CLI find it.
|
||||
"""
|
||||
java_status = self._dep_checker.check_java()
|
||||
if not java_status.available or not java_status.path:
|
||||
return {}
|
||||
|
||||
java_path = java_status.path
|
||||
# Get JAVA_HOME from the java binary path (bin/java -> parent -> parent)
|
||||
java_bin_dir = os.path.dirname(java_path) # /path/to/jdk/bin
|
||||
java_home = os.path.dirname(java_bin_dir) # /path/to/jdk
|
||||
|
||||
env = os.environ.copy()
|
||||
env["JAVA_HOME"] = java_home
|
||||
# Prepend Java bin dir to PATH
|
||||
env["PATH"] = f"{java_bin_dir}:{env.get('PATH', '')}"
|
||||
return env
|
||||
|
||||
def _find_config(self) -> Optional[str]:
|
||||
"""Find shared config file."""
|
||||
# Look relative to this module
|
||||
module_dir = Path(__file__).parent
|
||||
config = module_dir / "config" / "code-analyzer.yml"
|
||||
|
||||
if config.exists():
|
||||
return str(config)
|
||||
|
||||
# Also try code-analyzer.yaml
|
||||
config_yaml = module_dir / "config" / "code-analyzer.yaml"
|
||||
if config_yaml.exists():
|
||||
return str(config_yaml)
|
||||
|
||||
return None
|
||||
|
||||
def get_available_engines(self) -> List[str]:
|
||||
"""Get list of available engine names."""
|
||||
return self._dep_checker.get_available_engines()
|
||||
|
||||
def get_unavailable_engines(self) -> List[tuple]:
|
||||
"""Get list of unavailable engines with reasons."""
|
||||
return self._dep_checker.get_unavailable_engines()
|
||||
|
||||
def check_dependencies(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Check which dependencies are available.
|
||||
|
||||
Returns:
|
||||
Dict mapping dependency name to availability status
|
||||
"""
|
||||
deps = self._dep_checker.check_all()
|
||||
return {name: status.available for name, status in deps.items()}
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Code Analyzer is available at all."""
|
||||
deps = self.check_dependencies()
|
||||
return deps.get("sf_cli", False)
|
||||
|
||||
def scan(
|
||||
self,
|
||||
file_path: str,
|
||||
skill_type: SkillType,
|
||||
additional_rules: Optional[List[str]] = None,
|
||||
severity_threshold: Optional[int] = None,
|
||||
) -> ScanResult:
|
||||
"""
|
||||
Scan a file using Code Analyzer.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to scan
|
||||
skill_type: Type of skill (determines rule selection)
|
||||
additional_rules: Additional rule selectors to include
|
||||
severity_threshold: Only return violations >= this severity (1-5)
|
||||
|
||||
Returns:
|
||||
ScanResult with violations and metadata
|
||||
"""
|
||||
# Validate file exists
|
||||
if not os.path.exists(file_path):
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[],
|
||||
violation_counts={},
|
||||
error_message=f"File not found: {file_path}",
|
||||
)
|
||||
|
||||
# Check if sf CLI is available
|
||||
if not self.is_available():
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=["all"],
|
||||
violation_counts={},
|
||||
error_message="Salesforce CLI with Code Analyzer not available",
|
||||
)
|
||||
|
||||
# Get rule selectors for this skill type
|
||||
rule_selectors = list(self.RULE_SELECTORS.get(skill_type, []))
|
||||
if additional_rules:
|
||||
rule_selectors.extend(additional_rules)
|
||||
|
||||
# Filter to available engines only
|
||||
available = set(self.get_available_engines())
|
||||
unavailable_engines = []
|
||||
|
||||
filtered_selectors = []
|
||||
for selector in rule_selectors:
|
||||
# Extract engine name from selector (before first :)
|
||||
engine = selector.split(":")[0] if ":" in selector else selector
|
||||
if engine in available:
|
||||
filtered_selectors.append(selector)
|
||||
else:
|
||||
if engine not in [e for e, _ in unavailable_engines]:
|
||||
unavailable_engines.append((engine, f"Missing dependencies"))
|
||||
|
||||
if not filtered_selectors:
|
||||
return ScanResult(
|
||||
success=True,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[e for e, _ in unavailable_engines],
|
||||
violation_counts={"total": 0},
|
||||
error_message="No engines available for this skill type",
|
||||
)
|
||||
|
||||
# Create temp file for JSON output
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".json",
|
||||
delete=False,
|
||||
mode="w"
|
||||
) as f:
|
||||
output_file = f.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
"sf", "code-analyzer", "run",
|
||||
"--target", file_path,
|
||||
"--output-file", output_file,
|
||||
]
|
||||
|
||||
# Add config file if available
|
||||
if self.config_path and os.path.exists(self.config_path):
|
||||
cmd.extend(["--config-file", self.config_path])
|
||||
|
||||
# Add rule selectors
|
||||
for selector in filtered_selectors:
|
||||
cmd.extend(["--rule-selector", selector])
|
||||
|
||||
# Add severity threshold if specified
|
||||
if severity_threshold:
|
||||
cmd.extend(["--severity-threshold", str(severity_threshold)])
|
||||
|
||||
# Run scanner
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Use Java environment if available (for Homebrew/non-standard Java paths)
|
||||
env = self._java_env if self._java_env else None
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout_seconds,
|
||||
env=env
|
||||
)
|
||||
|
||||
scan_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Parse output
|
||||
if os.path.exists(output_file) and os.path.getsize(output_file) > 0:
|
||||
with open(output_file, "r") as f:
|
||||
raw_output = json.load(f)
|
||||
|
||||
return self._parse_output(
|
||||
raw_output,
|
||||
[e for e, _ in unavailable_engines],
|
||||
scan_time
|
||||
)
|
||||
else:
|
||||
# No output file - might be an error
|
||||
error_msg = result.stderr.strip() if result.stderr else "No output generated"
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[e for e, _ in unavailable_engines],
|
||||
violation_counts={},
|
||||
error_message=error_msg,
|
||||
scan_time_ms=scan_time,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[e for e, _ in unavailable_engines],
|
||||
violation_counts={"timeout": 1},
|
||||
error_message=f"Scan timed out after {self.timeout_seconds}s",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=["all"],
|
||||
violation_counts={"error": 1},
|
||||
error_message="sf CLI not found - install Salesforce CLI",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[e for e, _ in unavailable_engines],
|
||||
violation_counts={"error": 1},
|
||||
error_message=f"Failed to parse scanner output: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[],
|
||||
violation_counts={"error": 1},
|
||||
error_message=f"Scanner error: {e}",
|
||||
)
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
if os.path.exists(output_file):
|
||||
try:
|
||||
os.unlink(output_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_output(
|
||||
self,
|
||||
raw_output: Dict[str, Any],
|
||||
unavailable_engines: List[str],
|
||||
scan_time_ms: int,
|
||||
) -> ScanResult:
|
||||
"""Parse Code Analyzer JSON output into normalized format."""
|
||||
violations = []
|
||||
engines_used = set()
|
||||
|
||||
for violation in raw_output.get("violations", []):
|
||||
engine = violation.get("engine", "unknown")
|
||||
|
||||
# Skip engine errors (not actual code violations)
|
||||
# These include: UninstantiableEngineError, UnexpectedEngineError, etc.
|
||||
rule = violation.get("rule", "")
|
||||
if "Error" in rule and "Engine" in rule:
|
||||
continue
|
||||
|
||||
engines_used.add(engine)
|
||||
|
||||
# Get primary location
|
||||
locations = violation.get("locations", [])
|
||||
primary_idx = violation.get("primaryLocationIndex", 0)
|
||||
primary_loc = locations[primary_idx] if locations and primary_idx < len(locations) else {}
|
||||
|
||||
# Get severity
|
||||
severity = violation.get("severity", 5)
|
||||
severity_label = self.SEVERITY_LABELS.get(severity, "UNKNOWN")
|
||||
|
||||
# Normalize violation
|
||||
violations.append({
|
||||
"rule": rule,
|
||||
"engine": engine,
|
||||
"severity": severity,
|
||||
"severity_label": severity_label,
|
||||
"message": violation.get("message", ""),
|
||||
"file": primary_loc.get("file", ""),
|
||||
"line": primary_loc.get("startLine", 0),
|
||||
"end_line": primary_loc.get("endLine", 0),
|
||||
"column": primary_loc.get("startColumn", 0),
|
||||
"end_column": primary_loc.get("endColumn", 0),
|
||||
"tags": violation.get("tags", []),
|
||||
"resources": violation.get("resources", []),
|
||||
})
|
||||
|
||||
return ScanResult(
|
||||
success=True,
|
||||
violations=violations,
|
||||
engines_used=list(engines_used),
|
||||
engines_unavailable=unavailable_engines,
|
||||
violation_counts=raw_output.get("violationCounts", {}),
|
||||
raw_output=raw_output,
|
||||
scan_time_ms=scan_time_ms,
|
||||
)
|
||||
|
||||
def scan_directory(
|
||||
self,
|
||||
directory: str,
|
||||
skill_type: SkillType,
|
||||
recursive: bool = True,
|
||||
) -> ScanResult:
|
||||
"""
|
||||
Scan all files in a directory matching the skill type.
|
||||
|
||||
Args:
|
||||
directory: Directory path to scan
|
||||
skill_type: Type of skill (determines file extensions and rules)
|
||||
recursive: Whether to scan subdirectories
|
||||
|
||||
Returns:
|
||||
ScanResult with combined violations
|
||||
"""
|
||||
if not os.path.isdir(directory):
|
||||
return ScanResult(
|
||||
success=False,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[],
|
||||
violation_counts={},
|
||||
error_message=f"Directory not found: {directory}",
|
||||
)
|
||||
|
||||
# Find files matching skill type
|
||||
extensions = self.FILE_EXTENSIONS.get(skill_type, [])
|
||||
files_to_scan = []
|
||||
|
||||
if recursive:
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.endswith(ext) for ext in extensions):
|
||||
files_to_scan.append(os.path.join(root, file))
|
||||
else:
|
||||
for file in os.listdir(directory):
|
||||
if any(file.endswith(ext) for ext in extensions):
|
||||
files_to_scan.append(os.path.join(directory, file))
|
||||
|
||||
if not files_to_scan:
|
||||
return ScanResult(
|
||||
success=True,
|
||||
violations=[],
|
||||
engines_used=[],
|
||||
engines_unavailable=[],
|
||||
violation_counts={"total": 0},
|
||||
)
|
||||
|
||||
# Scan directory directly (more efficient than file by file)
|
||||
return self.scan(directory, skill_type)
|
||||
|
||||
|
||||
def get_skill_type_for_file(file_path: str) -> Optional[SkillType]:
|
||||
"""
|
||||
Determine skill type based on file extension.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
SkillType or None if unknown
|
||||
"""
|
||||
file_lower = file_path.lower()
|
||||
|
||||
if file_lower.endswith(".cls") or file_lower.endswith(".trigger"):
|
||||
return SkillType.APEX
|
||||
elif file_lower.endswith(".flow-meta.xml"):
|
||||
return SkillType.FLOW
|
||||
elif file_lower.endswith(".js") or file_lower.endswith(".html"):
|
||||
return SkillType.LWC
|
||||
elif file_lower.endswith("-meta.xml"):
|
||||
return SkillType.METADATA
|
||||
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo when run directly
|
||||
import sys
|
||||
|
||||
scanner = CodeAnalyzerScanner()
|
||||
|
||||
print("Code Analyzer Scanner Status")
|
||||
print("=" * 40)
|
||||
print(f"Available: {scanner.is_available()}")
|
||||
print(f"Config: {scanner.config_path or 'Not found'}")
|
||||
print()
|
||||
|
||||
deps = scanner.check_dependencies()
|
||||
print("Dependencies:")
|
||||
for dep, available in deps.items():
|
||||
status = "" if available else ""
|
||||
print(f" {status} {dep}")
|
||||
|
||||
print()
|
||||
print("Available engines:", scanner.get_available_engines())
|
||||
|
||||
unavailable = scanner.get_unavailable_engines()
|
||||
if unavailable:
|
||||
print("Unavailable engines:")
|
||||
for engine, reason in unavailable:
|
||||
print(f" - {engine}: {reason}")
|
||||
|
||||
# If file provided, scan it
|
||||
if len(sys.argv) > 1:
|
||||
file_path = sys.argv[1]
|
||||
skill_type = get_skill_type_for_file(file_path)
|
||||
|
||||
if skill_type:
|
||||
print(f"\nScanning {file_path} as {skill_type.value}...")
|
||||
result = scanner.scan(file_path, skill_type)
|
||||
|
||||
print(f"Success: {result.success}")
|
||||
print(f"Engines used: {result.engines_used}")
|
||||
print(f"Violations: {len(result.violations)}")
|
||||
|
||||
for v in result.violations[:10]:
|
||||
print(f" [{v['severity_label']}] {v['rule']}: {v['message'][:60]}")
|
||||
else:
|
||||
print(f"\nUnknown file type: {file_path}")
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Score Merger - Combines custom sf-skills scoring with Code Analyzer findings.
|
||||
|
||||
This module merges the existing sf-skills validation scores (150-point for Apex,
|
||||
110-point for Flow, etc.) with Salesforce Code Analyzer V5 findings to produce
|
||||
a unified report.
|
||||
|
||||
Strategy:
|
||||
- Custom scoring remains the primary score
|
||||
- Code Analyzer findings add additional deductions
|
||||
- Critical CA findings reduce score by up to 20 points total
|
||||
- High CA findings reduce by up to 10 points total
|
||||
- Duplicate findings (same rule) are deduplicated
|
||||
- Findings that overlap with custom scoring categories get mapped
|
||||
|
||||
Usage:
|
||||
merger = ScoreMerger(
|
||||
custom_scores={"bulkification": 25, "security": 20, ...},
|
||||
custom_max_scores={"bulkification": 25, "security": 25, ...}
|
||||
)
|
||||
merged = merger.merge(ca_violations)
|
||||
|
||||
print(f"Final score: {merged.final_score}/{merged.final_max}")
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ScoreCategory(Enum):
|
||||
"""Universal scoring categories across sf-skills."""
|
||||
BULKIFICATION = "bulkification"
|
||||
SECURITY = "security"
|
||||
TESTING = "testing"
|
||||
ARCHITECTURE = "architecture"
|
||||
CLEAN_CODE = "clean_code"
|
||||
ERROR_HANDLING = "error_handling"
|
||||
PERFORMANCE = "performance"
|
||||
DOCUMENTATION = "documentation"
|
||||
# Flow-specific
|
||||
DESIGN = "design"
|
||||
LOGIC = "logic"
|
||||
OBSERVABILITY = "observability"
|
||||
GOVERNANCE = "governance"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoreDeduction:
|
||||
"""A single score deduction from CA findings."""
|
||||
rule: str
|
||||
engine: str
|
||||
severity: int
|
||||
severity_label: str
|
||||
deduction: int
|
||||
category: Optional[str]
|
||||
message: str
|
||||
line: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergedScore:
|
||||
"""Combined score from custom validator + Code Analyzer."""
|
||||
custom_score: int
|
||||
custom_max: int
|
||||
ca_violations_total: int
|
||||
ca_critical: int
|
||||
ca_high: int
|
||||
ca_deductions: int
|
||||
final_score: int
|
||||
final_max: int
|
||||
rating: str
|
||||
rating_stars: int
|
||||
deductions: List[ScoreDeduction]
|
||||
engines_used: List[str] = field(default_factory=list)
|
||||
engines_unavailable: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ScoreMerger:
|
||||
"""
|
||||
Merges custom validation scores with Code Analyzer findings.
|
||||
|
||||
The merger applies deductions from CA violations on top of the custom score,
|
||||
with caps to prevent excessive penalization.
|
||||
"""
|
||||
|
||||
# Mapping from CA rules to sf-skills score categories
|
||||
RULE_CATEGORY_MAP = {
|
||||
# PMD Apex rules -> Categories
|
||||
"AvoidSoqlInLoops": ScoreCategory.BULKIFICATION,
|
||||
"AvoidDmlStatementsInLoops": ScoreCategory.BULKIFICATION,
|
||||
"OperationWithLimitsInLoop": ScoreCategory.BULKIFICATION,
|
||||
|
||||
"ApexCRUDViolation": ScoreCategory.SECURITY,
|
||||
"ApexSharingViolations": ScoreCategory.SECURITY,
|
||||
"ApexSOQLInjection": ScoreCategory.SECURITY,
|
||||
"ApexOpenRedirect": ScoreCategory.SECURITY,
|
||||
"ApexCSRF": ScoreCategory.SECURITY,
|
||||
"ApexBadCrypto": ScoreCategory.SECURITY,
|
||||
"ApexInsecureEndpoint": ScoreCategory.SECURITY,
|
||||
"ApexXSSFromURLParam": ScoreCategory.SECURITY,
|
||||
"ApexXSSFromEscapeFalse": ScoreCategory.SECURITY,
|
||||
|
||||
"ApexUnitTestClassShouldHaveAsserts": ScoreCategory.TESTING,
|
||||
"ApexUnitTestShouldNotUseSeeAllDataTrue": ScoreCategory.TESTING,
|
||||
"ApexAssertionsShouldIncludeMessage": ScoreCategory.TESTING,
|
||||
"ApexUnitTestMethodShouldHaveIsTestAnnotation": ScoreCategory.TESTING,
|
||||
|
||||
"CyclomaticComplexity": ScoreCategory.CLEAN_CODE,
|
||||
"ExcessiveParameterList": ScoreCategory.CLEAN_CODE,
|
||||
"ExcessiveClassLength": ScoreCategory.CLEAN_CODE,
|
||||
"ExcessivePublicCount": ScoreCategory.CLEAN_CODE,
|
||||
"TooManyFields": ScoreCategory.CLEAN_CODE,
|
||||
"NcssMethodCount": ScoreCategory.CLEAN_CODE,
|
||||
"NcssTypeCount": ScoreCategory.CLEAN_CODE,
|
||||
"NcssConstructorCount": ScoreCategory.CLEAN_CODE,
|
||||
"AvoidGlobalModifier": ScoreCategory.CLEAN_CODE,
|
||||
"AvoidHardcodingId": ScoreCategory.CLEAN_CODE,
|
||||
"AvoidLogicInTrigger": ScoreCategory.ARCHITECTURE,
|
||||
"DebugsShouldUseLoggingLevel": ScoreCategory.CLEAN_CODE,
|
||||
|
||||
"EmptyCatchBlock": ScoreCategory.ERROR_HANDLING,
|
||||
"EmptyTryOrFinallyBlock": ScoreCategory.ERROR_HANDLING,
|
||||
"EmptyStatementBlock": ScoreCategory.ERROR_HANDLING,
|
||||
|
||||
"AvoidHardcodingId": ScoreCategory.PERFORMANCE,
|
||||
"OperationWithLimitsInLoop": ScoreCategory.PERFORMANCE,
|
||||
|
||||
"ApexDoc": ScoreCategory.DOCUMENTATION,
|
||||
|
||||
# Regex rules
|
||||
"HardcodedSalesforceUrl": ScoreCategory.CLEAN_CODE,
|
||||
"MissingWithSharing": ScoreCategory.SECURITY,
|
||||
"DeprecatedTestIsRunning": ScoreCategory.TESTING,
|
||||
|
||||
# Flow rules
|
||||
"DbInLoop": ScoreCategory.BULKIFICATION,
|
||||
"GetRecordsInLoop": ScoreCategory.BULKIFICATION,
|
||||
"CyclicSubflow": ScoreCategory.ARCHITECTURE,
|
||||
"MissingFaultHandler": ScoreCategory.ERROR_HANDLING,
|
||||
"MissingFaultPath": ScoreCategory.ERROR_HANDLING,
|
||||
"HardcodedId": ScoreCategory.CLEAN_CODE,
|
||||
"MissingDescription": ScoreCategory.DOCUMENTATION,
|
||||
"MissingNullHandler": ScoreCategory.ERROR_HANDLING,
|
||||
"UnusedVariable": ScoreCategory.CLEAN_CODE,
|
||||
}
|
||||
|
||||
# Deduction points per severity
|
||||
SEVERITY_DEDUCTIONS = {
|
||||
1: 5, # Critical: -5 per violation
|
||||
2: 3, # High: -3 per violation
|
||||
3: 1, # Moderate: -1 per violation
|
||||
4: 0, # Low: informational only
|
||||
5: 0, # Info: informational only
|
||||
}
|
||||
|
||||
# Maximum total deductions by severity (caps)
|
||||
MAX_DEDUCTIONS_BY_SEVERITY = {
|
||||
1: 20, # Max -20 from critical violations
|
||||
2: 10, # Max -10 from high violations
|
||||
3: 5, # Max -5 from moderate violations
|
||||
}
|
||||
|
||||
# Overall maximum deduction from CA
|
||||
MAX_TOTAL_DEDUCTION = 30
|
||||
|
||||
# Rating thresholds (percentage of max score)
|
||||
RATING_THRESHOLDS = [
|
||||
(90, "Excellent", 5),
|
||||
(75, "Very Good", 4),
|
||||
(60, "Good", 3),
|
||||
(45, "Needs Work", 2),
|
||||
(0, "Critical Issues", 1),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
custom_scores: Dict[str, int],
|
||||
custom_max_scores: Dict[str, int],
|
||||
):
|
||||
"""
|
||||
Initialize merger with custom validation scores.
|
||||
|
||||
Args:
|
||||
custom_scores: Dict of category -> current score
|
||||
custom_max_scores: Dict of category -> max possible score
|
||||
"""
|
||||
self.custom_scores = custom_scores
|
||||
self.custom_max_scores = custom_max_scores
|
||||
self.deductions: List[ScoreDeduction] = []
|
||||
|
||||
def merge(
|
||||
self,
|
||||
ca_violations: List[Dict[str, Any]],
|
||||
engines_used: Optional[List[str]] = None,
|
||||
engines_unavailable: Optional[List[str]] = None,
|
||||
) -> MergedScore:
|
||||
"""
|
||||
Merge Code Analyzer violations with custom scores.
|
||||
|
||||
Args:
|
||||
ca_violations: List of normalized violations from CodeAnalyzerScanner
|
||||
engines_used: List of engines that ran
|
||||
engines_unavailable: List of engines that couldn't run
|
||||
|
||||
Returns:
|
||||
MergedScore with combined results
|
||||
"""
|
||||
self.deductions = []
|
||||
|
||||
# Track deductions by severity
|
||||
severity_totals = {1: 0, 2: 0, 3: 0}
|
||||
processed_rules = set() # Dedupe same rule violations
|
||||
|
||||
critical_count = 0
|
||||
high_count = 0
|
||||
|
||||
for violation in ca_violations:
|
||||
rule = violation.get("rule", "")
|
||||
severity = violation.get("severity", 5)
|
||||
line = violation.get("line", 0)
|
||||
|
||||
# Create unique key for deduplication (rule + line)
|
||||
dedup_key = f"{rule}:{line}"
|
||||
if dedup_key in processed_rules:
|
||||
continue
|
||||
processed_rules.add(dedup_key)
|
||||
|
||||
# Count by severity
|
||||
if severity == 1:
|
||||
critical_count += 1
|
||||
elif severity == 2:
|
||||
high_count += 1
|
||||
|
||||
# Calculate deduction
|
||||
base_deduction = self.SEVERITY_DEDUCTIONS.get(severity, 0)
|
||||
if base_deduction <= 0:
|
||||
continue # No deduction for low/info
|
||||
|
||||
# Check against severity cap
|
||||
current_total = severity_totals.get(severity, 0)
|
||||
max_for_severity = self.MAX_DEDUCTIONS_BY_SEVERITY.get(severity, 0)
|
||||
actual_deduction = min(base_deduction, max_for_severity - current_total)
|
||||
|
||||
if actual_deduction <= 0:
|
||||
continue # Cap reached for this severity
|
||||
|
||||
severity_totals[severity] += actual_deduction
|
||||
|
||||
# Map to category
|
||||
category = self.RULE_CATEGORY_MAP.get(rule)
|
||||
category_name = category.value if category else "general"
|
||||
|
||||
self.deductions.append(ScoreDeduction(
|
||||
rule=rule,
|
||||
engine=violation.get("engine", "unknown"),
|
||||
severity=severity,
|
||||
severity_label=violation.get("severity_label", "UNKNOWN"),
|
||||
deduction=actual_deduction,
|
||||
category=category_name,
|
||||
message=violation.get("message", "")[:100],
|
||||
line=line,
|
||||
))
|
||||
|
||||
# Calculate totals
|
||||
custom_total = sum(self.custom_scores.values())
|
||||
custom_max = sum(self.custom_max_scores.values())
|
||||
|
||||
total_deductions = sum(severity_totals.values())
|
||||
|
||||
# Apply overall cap
|
||||
total_deductions = min(total_deductions, self.MAX_TOTAL_DEDUCTION)
|
||||
|
||||
final_score = max(0, custom_total - total_deductions)
|
||||
|
||||
# Calculate rating
|
||||
rating, rating_stars = self._calculate_rating(final_score, custom_max)
|
||||
|
||||
return MergedScore(
|
||||
custom_score=custom_total,
|
||||
custom_max=custom_max,
|
||||
ca_violations_total=len(ca_violations),
|
||||
ca_critical=critical_count,
|
||||
ca_high=high_count,
|
||||
ca_deductions=total_deductions,
|
||||
final_score=final_score,
|
||||
final_max=custom_max,
|
||||
rating=rating,
|
||||
rating_stars=rating_stars,
|
||||
deductions=self.deductions,
|
||||
engines_used=engines_used or [],
|
||||
engines_unavailable=engines_unavailable or [],
|
||||
)
|
||||
|
||||
def _calculate_rating(self, score: int, max_score: int) -> tuple:
|
||||
"""Calculate star rating based on percentage."""
|
||||
if max_score == 0:
|
||||
return "N/A", 0
|
||||
|
||||
percentage = (score / max_score) * 100
|
||||
|
||||
for threshold, label, stars in self.RATING_THRESHOLDS:
|
||||
if percentage >= threshold:
|
||||
return label, stars
|
||||
|
||||
return "Critical Issues", 1
|
||||
|
||||
def get_category_impact(self) -> Dict[str, int]:
|
||||
"""
|
||||
Get total deductions by category.
|
||||
|
||||
Returns:
|
||||
Dict mapping category name to total deduction points
|
||||
"""
|
||||
impact = {}
|
||||
for d in self.deductions:
|
||||
cat = d.category or "general"
|
||||
impact[cat] = impact.get(cat, 0) + d.deduction
|
||||
return impact
|
||||
|
||||
|
||||
def merge_scores(
|
||||
custom_scores: Dict[str, int],
|
||||
custom_max_scores: Dict[str, int],
|
||||
ca_violations: List[Dict[str, Any]],
|
||||
engines_used: Optional[List[str]] = None,
|
||||
engines_unavailable: Optional[List[str]] = None,
|
||||
) -> MergedScore:
|
||||
"""
|
||||
Convenience function to merge scores.
|
||||
|
||||
Args:
|
||||
custom_scores: Dict of category -> current score
|
||||
custom_max_scores: Dict of category -> max possible score
|
||||
ca_violations: List of normalized CA violations
|
||||
engines_used: List of engines that ran
|
||||
engines_unavailable: List of engines that couldn't run
|
||||
|
||||
Returns:
|
||||
MergedScore with combined results
|
||||
"""
|
||||
merger = ScoreMerger(custom_scores, custom_max_scores)
|
||||
return merger.merge(ca_violations, engines_used, engines_unavailable)
|
||||
|
||||
|
||||
def format_rating_stars(stars: int) -> str:
|
||||
"""Format rating as star icons."""
|
||||
return "" * stars + "" * (5 - stars)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo with sample data
|
||||
custom_scores = {
|
||||
"bulkification": 25,
|
||||
"security": 20,
|
||||
"testing": 25,
|
||||
"architecture": 18,
|
||||
"clean_code": 20,
|
||||
"error_handling": 15,
|
||||
"performance": 10,
|
||||
"documentation": 7,
|
||||
}
|
||||
|
||||
custom_max = {
|
||||
"bulkification": 25,
|
||||
"security": 25,
|
||||
"testing": 25,
|
||||
"architecture": 20,
|
||||
"clean_code": 20,
|
||||
"error_handling": 15,
|
||||
"performance": 10,
|
||||
"documentation": 10,
|
||||
}
|
||||
|
||||
ca_violations = [
|
||||
{
|
||||
"rule": "AvoidSoqlInLoops",
|
||||
"engine": "pmd",
|
||||
"severity": 1,
|
||||
"severity_label": "CRITICAL",
|
||||
"message": "SOQL query found inside loop",
|
||||
"line": 25,
|
||||
},
|
||||
{
|
||||
"rule": "EmptyCatchBlock",
|
||||
"engine": "pmd",
|
||||
"severity": 2,
|
||||
"severity_label": "HIGH",
|
||||
"message": "Empty catch block swallows exception",
|
||||
"line": 40,
|
||||
},
|
||||
{
|
||||
"rule": "CyclomaticComplexity",
|
||||
"engine": "pmd",
|
||||
"severity": 3,
|
||||
"severity_label": "MODERATE",
|
||||
"message": "Method complexity is 15 (threshold 10)",
|
||||
"line": 50,
|
||||
},
|
||||
]
|
||||
|
||||
merged = merge_scores(
|
||||
custom_scores,
|
||||
custom_max,
|
||||
ca_violations,
|
||||
engines_used=["pmd", "regex"],
|
||||
engines_unavailable=["sfge"],
|
||||
)
|
||||
|
||||
print(f"Custom Score: {merged.custom_score}/{merged.custom_max}")
|
||||
print(f"CA Violations: {merged.ca_violations_total} ({merged.ca_critical} critical, {merged.ca_high} high)")
|
||||
print(f"CA Deductions: -{merged.ca_deductions}")
|
||||
print(f"Final Score: {merged.final_score}/{merged.final_max}")
|
||||
print(f"Rating: {format_rating_stars(merged.rating_stars)} {merged.rating}")
|
||||
print()
|
||||
print("Deductions:")
|
||||
for d in merged.deductions:
|
||||
print(f" -{d.deduction} [{d.category}] {d.rule}: {d.message}")
|
||||
Reference in New Issue
Block a user