mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-31 04:01:24 +08:00
* ci: update package details and add GitHub workflows for skills validation and release * chore: update Node version, change license, and refactor skills validation script to TypeScript * chore: remove unused deps * fix: npm package * fix: flatten skills to pass validation * chore: add validation script to package.json and update GitHub workflow to use it * chore: enhance skills validation and update workflows for npm publishing * refactor: streamline skills validation process in workflow and script * refactor: improve documentation and structure of skills validation script * implement checks based on jeff's best practices doc * chore: add pull request template for skill submissions * chore: update pull request template with additional references and improve automated checks section * chore: update pull request template to clarify naming convention with a warning for gerund form * chore: update pull request template to reference skill authoring guide and enhance checklist structure * chore: enhance GitHub workflows for skills validation and release process * refactor: improve parsing logic for SKILL.md files and enhance error collection in validation * chore: update validation checks to enforce character limits for skill names and descriptions * fix: remove unnecessary whitespace in footer string in validate-skills.ts
259 lines
9.2 KiB
Python
259 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Analyze Salesforce Apex triggers for common anti-patterns.
|
|
|
|
Usage:
|
|
python analyze_trigger.py <TriggerName>
|
|
|
|
Requirements:
|
|
- Salesforce CLI authenticated to target org
|
|
- Python 3.9+
|
|
"""
|
|
|
|
import sys
|
|
import subprocess
|
|
import json
|
|
import re
|
|
from typing import List, Dict, Tuple
|
|
|
|
class TriggerAnalyzer:
|
|
def __init__(self, trigger_name: str):
|
|
self.trigger_name = trigger_name
|
|
self.trigger_body = ""
|
|
self.issues = {
|
|
'dml_in_loops': [],
|
|
'soql_in_loops': [],
|
|
'missing_bulk': [],
|
|
}
|
|
self.complexity_score = 0
|
|
|
|
def retrieve_trigger(self) -> bool:
|
|
"""Retrieve trigger source code from Salesforce org."""
|
|
try:
|
|
cmd = [
|
|
'sf', 'apex', 'get', 'trigger',
|
|
'--trigger-name', self.trigger_name,
|
|
'--json'
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
print(f"Error retrieving trigger: {result.stderr}")
|
|
return False
|
|
|
|
data = json.loads(result.stdout)
|
|
self.trigger_body = data.get('result', {}).get('body', '')
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Failed to retrieve trigger: {e}")
|
|
return False
|
|
|
|
def analyze_dml_in_loops(self) -> None:
|
|
"""Detect DML operations inside for loops."""
|
|
lines = self.trigger_body.split('\n')
|
|
in_loop = False
|
|
loop_start = 0
|
|
|
|
dml_patterns = [
|
|
r'\binsert\s+', r'\bupdate\s+', r'\bdelete\s+',
|
|
r'\bundelete\s+', r'\bupsert\s+'
|
|
]
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
# Track loop entry
|
|
if re.search(r'\bfor\s*\(', line):
|
|
in_loop = True
|
|
loop_start = i
|
|
|
|
# Track loop exit
|
|
if in_loop and line.strip() == '}':
|
|
# Check if this closes the loop (simplified heuristic)
|
|
in_loop = False
|
|
|
|
# Check for DML in loop
|
|
if in_loop:
|
|
for pattern in dml_patterns:
|
|
if re.search(pattern, line):
|
|
self.issues['dml_in_loops'].append({
|
|
'line': i,
|
|
'code': line.strip(),
|
|
'loop_start': loop_start
|
|
})
|
|
|
|
def analyze_soql_in_loops(self) -> None:
|
|
"""Detect SOQL queries inside for loops."""
|
|
lines = self.trigger_body.split('\n')
|
|
in_loop = False
|
|
loop_start = 0
|
|
|
|
soql_pattern = r'\[SELECT\s+'
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
if re.search(r'\bfor\s*\(', line):
|
|
in_loop = True
|
|
loop_start = i
|
|
|
|
if in_loop and line.strip() == '}':
|
|
in_loop = False
|
|
|
|
if in_loop and re.search(soql_pattern, line, re.IGNORECASE):
|
|
self.issues['soql_in_loops'].append({
|
|
'line': i,
|
|
'code': line.strip(),
|
|
'loop_start': loop_start
|
|
})
|
|
|
|
def analyze_bulkification(self) -> None:
|
|
"""Check for proper bulkification patterns."""
|
|
# Simple heuristic: if we have DML in loops, bulkification is missing
|
|
if self.issues['dml_in_loops']:
|
|
self.issues['missing_bulk'].append({
|
|
'message': 'DML operations should be collected and executed outside loops',
|
|
'affected_lines': [issue['line'] for issue in self.issues['dml_in_loops']]
|
|
})
|
|
|
|
if self.issues['soql_in_loops']:
|
|
self.issues['missing_bulk'].append({
|
|
'message': 'SOQL queries should be moved outside loops or use Maps for lookups',
|
|
'affected_lines': [issue['line'] for issue in self.issues['soql_in_loops']]
|
|
})
|
|
|
|
def calculate_complexity(self) -> None:
|
|
"""Calculate overall complexity score (1-10)."""
|
|
score = 1
|
|
|
|
# Add points for each issue type
|
|
score += len(self.issues['dml_in_loops']) * 2
|
|
score += len(self.issues['soql_in_loops']) * 2
|
|
score += len(self.issues['missing_bulk']) * 1
|
|
|
|
# Count trigger contexts
|
|
contexts = len(re.findall(r'Trigger\.(isBefore|isAfter)', self.trigger_body))
|
|
score += contexts
|
|
|
|
# Count lines of code (normalized)
|
|
loc = len([l for l in self.trigger_body.split('\n') if l.strip()])
|
|
score += min(loc // 10, 3)
|
|
|
|
self.complexity_score = min(score, 10)
|
|
|
|
def recommend_approach(self) -> str:
|
|
"""Recommend refactoring approach based on analysis."""
|
|
if self.complexity_score <= 3:
|
|
return "Simple handler class with separate methods for each trigger context"
|
|
elif self.complexity_score <= 6:
|
|
return "Handler class with bulkified collections and helper methods"
|
|
else:
|
|
return "Unified handler framework with separate concern classes (validation, DML, etc.)"
|
|
|
|
def generate_report(self) -> None:
|
|
"""Print analysis report."""
|
|
print("\n" + "="*70)
|
|
print(f"TRIGGER ANALYSIS REPORT: {self.trigger_name}")
|
|
print("="*70 + "\n")
|
|
|
|
print(f"Complexity Score: {self.complexity_score}/10")
|
|
print(f"Recommended Approach: {self.recommend_approach()}\n")
|
|
|
|
# DML in loops
|
|
if self.issues['dml_in_loops']:
|
|
print("⚠️ DML OPERATIONS IN LOOPS:")
|
|
for issue in self.issues['dml_in_loops']:
|
|
print(f" Line {issue['line']}: {issue['code']}")
|
|
print(f" └─ Loop started at line {issue['loop_start']}")
|
|
print()
|
|
else:
|
|
print("✓ No DML operations found in loops\n")
|
|
|
|
# SOQL in loops
|
|
if self.issues['soql_in_loops']:
|
|
print("⚠️ SOQL QUERIES IN LOOPS:")
|
|
for issue in self.issues['soql_in_loops']:
|
|
print(f" Line {issue['line']}: {issue['code']}")
|
|
print(f" └─ Loop started at line {issue['loop_start']}")
|
|
print()
|
|
else:
|
|
print("✓ No SOQL queries found in loops\n")
|
|
|
|
# Bulkification
|
|
if self.issues['missing_bulk']:
|
|
print("⚠️ BULKIFICATION RECOMMENDATIONS:")
|
|
for issue in self.issues['missing_bulk']:
|
|
print(f" • {issue['message']}")
|
|
print(f" Affected lines: {', '.join(map(str, issue['affected_lines']))}")
|
|
print()
|
|
else:
|
|
print("✓ Bulkification patterns look good\n")
|
|
|
|
print("="*70)
|
|
print("NEXT STEPS:")
|
|
print("1. Review the handler patterns reference guide")
|
|
print("2. Create handler class with bulk-safe collections")
|
|
print("3. Extract trigger logic into handler methods")
|
|
print("4. Generate comprehensive tests using the template")
|
|
print("="*70 + "\n")
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python analyze_trigger.py <TriggerName>")
|
|
sys.exit(1)
|
|
|
|
trigger_name = sys.argv[1]
|
|
|
|
print(f"Analyzing trigger: {trigger_name}...")
|
|
|
|
analyzer = TriggerAnalyzer(trigger_name)
|
|
|
|
# For demo purposes, use inline example if retrieval fails
|
|
if not analyzer.retrieve_trigger():
|
|
print("⚠️ Could not retrieve from org. Using example trigger for demonstration.\n")
|
|
# Use the example from the SKILL.md
|
|
analyzer.trigger_body = """trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
|
|
if (Trigger.isBefore && Trigger.isInsert) {
|
|
for (Opportunity o : Trigger.new) {
|
|
if (o.StageName == 'Closed Won' && (o.Amount == null || o.Amount < 1000)) {
|
|
o.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
|
}
|
|
}
|
|
}
|
|
if (Trigger.isBefore && Trigger.isUpdate) {
|
|
for (Opportunity o : Trigger.new) {
|
|
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
|
if (o.StageName != oldO.StageName) {
|
|
o.Description = 'Stage changed from ' + oldO.StageName + ' to ' + o.StageName;
|
|
}
|
|
}
|
|
}
|
|
if (Trigger.isAfter && Trigger.isUpdate) {
|
|
for (Opportunity o : Trigger.new) {
|
|
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
|
if (o.StageName == 'Closed Won' && oldO.StageName != 'Closed Won') {
|
|
Task t = new Task(
|
|
WhatId = o.Id,
|
|
OwnerId = o.OwnerId,
|
|
Subject = 'Send thank-you',
|
|
Status = 'Not Started',
|
|
Priority = 'Normal',
|
|
ActivityDate = Date.today()
|
|
);
|
|
insert t;
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
|
|
# Run analysis
|
|
analyzer.analyze_dml_in_loops()
|
|
analyzer.analyze_soql_in_loops()
|
|
analyzer.analyze_bulkification()
|
|
analyzer.calculate_complexity()
|
|
|
|
# Generate report
|
|
analyzer.generate_report()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|