mirror of
https://github.com/Jaganpro/sf-skills.git
synced 2026-09-19 07:52:00 +08:00
8ace846293
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
457 lines
11 KiB
Python
457 lines
11 KiB
Python
#!/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}")
|