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:
Jaganpro
2025-12-14 11:22:33 -05:00
parent 3fcb3be401
commit 8ace846293
14 changed files with 3536 additions and 6 deletions
+12 -2
View File
@@ -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
}
]
}
+302
View File
@@ -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())
+7 -2
View File
@@ -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():
+12 -2
View File
@@ -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
}
]
}
+302
View File
@@ -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())
+47
View File
@@ -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
+531
View File
@@ -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())
+332
View File
@@ -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)
+456
View File
@@ -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}")
+554
View File
@@ -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}")
+419
View File
@@ -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}")