Files
jaganpro__sf-skills/shared/code_analyzer/score_merger.py
T
Jaganpro 8ace846293 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
2025-12-14 11:22:33 -05:00

420 lines
14 KiB
Python

#!/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}")