mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
* Migrating Core Salesforce Skills * Updating pr comments * updat reference * Updating a skill * Migrating Datacloud skills * Migrating Industries cloud skills * Validating - skills fixing --------- Co-authored-by: Sandip Kumar Yadav <sandipkumar.yadav+sfemu@salesforce.com>
348 lines
15 KiB
Python
348 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Post-Tool Validation Hook for generating-lwc-components plugin.
|
|
|
|
This hook runs AFTER Write or Edit tool completes and provides SLDS 2
|
|
validation feedback for LWC files (*.html, *.css, *.js).
|
|
|
|
Integrates:
|
|
1. Custom 140-point SLDS 2 scoring (7 categories)
|
|
2. Official SLDS Linter (if available via npm)
|
|
3. Salesforce Code Analyzer V5 (ESLint + retire-js engines for JS files)
|
|
|
|
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
|
|
from pathlib import Path
|
|
|
|
# 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 — try installed path first, then dev repo path
|
|
_CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
|
|
_CODE_ANALYZER_CANDIDATES = [
|
|
os.path.join(_CLAUDE_DIR, "code_analyzer"), # Installed path
|
|
os.path.join(SCRIPT_DIR, "..", "..", "..", "..", "shared", "code_analyzer"), # Dev repo
|
|
]
|
|
for _ca_path in _CODE_ANALYZER_CANDIDATES:
|
|
_ca_path = os.path.normpath(_ca_path)
|
|
if os.path.isdir(_ca_path):
|
|
sys.path.insert(0, os.path.dirname(_ca_path))
|
|
break
|
|
|
|
# Also add shared dir for other shared modules
|
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # generating-lwc-components/
|
|
SKILLS_ROOT = os.path.dirname(PLUGIN_ROOT)
|
|
SHARED_DIR = os.path.join(SKILLS_ROOT, "shared")
|
|
if os.path.isdir(SHARED_DIR):
|
|
sys.path.insert(0, SHARED_DIR)
|
|
|
|
# Supported LWC file extensions
|
|
LWC_EXTENSIONS = {'.html', '.css', '.js'}
|
|
|
|
|
|
def is_lwc_file(file_path: str) -> bool:
|
|
"""
|
|
Check if file is an LWC component file.
|
|
|
|
We validate .html (templates), .css (styles), and .js (controllers).
|
|
"""
|
|
ext = Path(file_path).suffix.lower()
|
|
return ext in LWC_EXTENSIONS
|
|
|
|
|
|
def validate_lwc_file(file_path: str) -> dict:
|
|
"""
|
|
Run comprehensive SLDS 2 validation on an LWC file.
|
|
|
|
Args:
|
|
file_path: Path to .html, .css, or .js file
|
|
|
|
Returns:
|
|
dict with validation results and output message
|
|
"""
|
|
output_parts = []
|
|
file_name = os.path.basename(file_path)
|
|
ext = Path(file_path).suffix.lower()
|
|
|
|
try:
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# PHASE 1: Custom 140-point SLDS 2 validation
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
from validate_slds import SLDSValidator
|
|
|
|
validator = SLDSValidator(file_path)
|
|
results = validator.validate()
|
|
|
|
score = results.get('score', 0)
|
|
max_score = results.get('max_score', 140)
|
|
issues = results.get('issues', [])
|
|
scores = results.get('scores', {})
|
|
rating = results.get('rating', '')
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# PHASE 1.5: LWC Template Anti-Pattern Validation (for HTML files)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
if ext == '.html':
|
|
try:
|
|
from template_validator import LWCTemplateValidator
|
|
template_validator = LWCTemplateValidator(file_path)
|
|
template_results = template_validator.validate()
|
|
template_issues = template_results.get('issues', [])
|
|
|
|
# Add template issues to main issues list
|
|
for tpl_issue in template_issues:
|
|
issues.append({
|
|
'severity': tpl_issue.get('severity', 'WARNING'),
|
|
'category': 'template_' + tpl_issue.get('category', 'pattern'),
|
|
'message': tpl_issue.get('message', ''),
|
|
'line': tpl_issue.get('line', 0),
|
|
'fix': tpl_issue.get('fix', ''),
|
|
'source': 'template-validator'
|
|
})
|
|
|
|
# Deduct from score for critical template issues
|
|
if tpl_issue.get('severity') == 'CRITICAL':
|
|
scores['component_structure'] = max(0, scores['component_structure'] - 3)
|
|
except ImportError:
|
|
pass # Template validator not available
|
|
except Exception:
|
|
pass # Don't fail validation on template check errors
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# PHASE 2: Official SLDS Linter (if available)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
linter_available = False
|
|
linter_violations = []
|
|
|
|
try:
|
|
from slds_linter_wrapper import SLDSLinterWrapper
|
|
|
|
linter = SLDSLinterWrapper()
|
|
if linter.is_available() and ext in {'.html', '.css'}:
|
|
linter_available = True
|
|
linter_result = linter.lint_file(file_path)
|
|
|
|
if linter_result.get('success'):
|
|
linter_violations = linter_result.get('violations', [])
|
|
|
|
# Merge linter violations with issues
|
|
for v in linter_violations:
|
|
issues.append({
|
|
'severity': v.get('severity', 'WARNING'),
|
|
'category': 'slds_linter',
|
|
'message': f"[{v.get('rule', 'slds')}] {v.get('message', '')}",
|
|
'line': v.get('line', 0),
|
|
'source': 'slds-linter'
|
|
})
|
|
except ImportError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# PHASE 2.5: Code Analyzer V5 scanning (ESLint + retire-js for JS files)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
ca_violations = []
|
|
ca_engines_used = []
|
|
ca_engines_unavailable = []
|
|
ca_available = False
|
|
scan_time_ms = 0
|
|
|
|
# Only run CA on .js files (ESLint/retire-js don't apply to HTML/CSS)
|
|
if ext == '.js':
|
|
try:
|
|
from code_analyzer.scanner import CodeAnalyzerScanner, SkillType
|
|
|
|
scanner = CodeAnalyzerScanner()
|
|
|
|
if scanner.is_available():
|
|
ca_available = True
|
|
scan_result = scanner.scan(file_path, SkillType.LWC)
|
|
|
|
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
|
|
|
|
# Merge CA violations with issues
|
|
for v in ca_violations:
|
|
issues.append({
|
|
'severity': v.get('severity_label', 'WARNING'),
|
|
'category': 'code_analyzer',
|
|
'message': f"[{v.get('engine', 'CA')}:{v.get('rule', '')}] {v.get('message', '')}",
|
|
'line': v.get('line', 0),
|
|
'source': f"CA:{v.get('engine', '')}"
|
|
})
|
|
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 rating
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
pct = (score / max_score * 100) if max_score > 0 else 0
|
|
if pct >= 90:
|
|
rating_stars = 5
|
|
rating = "Production-ready SLDS 2"
|
|
elif pct >= 80:
|
|
rating_stars = 4
|
|
rating = "Good"
|
|
elif pct >= 70:
|
|
rating_stars = 3
|
|
rating = "Functional"
|
|
elif pct >= 60:
|
|
rating_stars = 2
|
|
rating = "Needs work"
|
|
else:
|
|
rating_stars = 1
|
|
rating = "Critical issues"
|
|
|
|
stars = "⭐" * rating_stars + "☆" * (5 - rating_stars)
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# PHASE 4: Format output
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
output_parts.append("")
|
|
output_parts.append(f"🎨 SLDS 2 Validation: {file_name}")
|
|
output_parts.append("═" * 60)
|
|
output_parts.append(f"📊 Score: {score}/{max_score} {stars} {rating}")
|
|
|
|
# Category breakdown
|
|
if scores:
|
|
output_parts.append("")
|
|
output_parts.append("📋 Category Breakdown:")
|
|
for cat, cat_score in scores.items():
|
|
max_cat = validator.max_scores.get(cat, 0)
|
|
if max_cat > 0:
|
|
icon = "✅" if cat_score == max_cat else ("⚠️" if cat_score >= max_cat * 0.7 else "❌")
|
|
diff = f" (-{max_cat - cat_score})" if cat_score < max_cat else ""
|
|
display_name = cat.replace("_", " ").title()
|
|
output_parts.append(f" {icon} {display_name}: {cat_score}/{max_cat}{diff}")
|
|
|
|
# SLDS Linter status
|
|
output_parts.append("")
|
|
if linter_available:
|
|
output_parts.append("🔧 SLDS Linter: Active")
|
|
else:
|
|
output_parts.append("💡 SLDS Linter: Not installed")
|
|
output_parts.append(" Install: npm i -g @salesforce-ux/slds-linter")
|
|
|
|
# Code Analyzer status (for JS files only)
|
|
if ext == '.js':
|
|
output_parts.append("")
|
|
if ca_engines_used:
|
|
output_parts.append(f"🔍 Code Analyzer: {', '.join(ca_engines_used)}")
|
|
if scan_time_ms > 0:
|
|
output_parts.append(f" Scan time: {scan_time_ms}ms")
|
|
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[:2]:
|
|
output_parts.append(f" ⚠️ {unavail}")
|
|
|
|
# Issues list
|
|
if issues:
|
|
output_parts.append("")
|
|
output_parts.append(f"⚠️ Issues Found ({len(issues)}):")
|
|
|
|
# Sort by severity
|
|
severity_order = {'CRITICAL': 0, 'HIGH': 1, 'MODERATE': 2, 'WARNING': 3, 'LOW': 4, 'INFO': 5}
|
|
issues.sort(key=lambda x: severity_order.get(x.get('severity', 'INFO'), 5))
|
|
|
|
# Display up to 10 issues
|
|
for issue in issues[:10]:
|
|
sev = issue.get('severity', 'INFO')
|
|
icon = {'CRITICAL': '🔴', 'HIGH': '🟠', 'MODERATE': '🟡', 'WARNING': '🟡', 'LOW': '🔵', 'INFO': '⚪'}.get(sev, '⚪')
|
|
line_info = f"L{issue.get('line', '?')}" if issue.get('line') else ""
|
|
message = issue.get('message', '')[:60]
|
|
output_parts.append(f" {icon} {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(issues) > 10:
|
|
output_parts.append(f" ... and {len(issues) - 10} more issues")
|
|
else:
|
|
output_parts.append("")
|
|
output_parts.append("✅ No SLDS issues found!")
|
|
|
|
output_parts.append("═" * 60)
|
|
|
|
return {
|
|
"continue": True,
|
|
"output": "\n".join(output_parts)
|
|
}
|
|
|
|
except ImportError as e:
|
|
return {
|
|
"continue": True,
|
|
"output": f"⚠️ SLDS validator not available: {e}"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"continue": True,
|
|
"output": f"⚠️ SLDS validation error: {e}"
|
|
}
|
|
|
|
|
|
def main():
|
|
"""
|
|
Main hook entry point.
|
|
|
|
Reads hook input from stdin, validates LWC 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):
|
|
print(json.dumps({"continue": True}))
|
|
return 0
|
|
|
|
# Only validate LWC files
|
|
result = {"continue": True}
|
|
|
|
if is_lwc_file(file_path):
|
|
result = validate_lwc_file(file_path)
|
|
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
except json.JSONDecodeError:
|
|
print(json.dumps({"continue": True}))
|
|
return 0
|
|
except Exception as e:
|
|
print(json.dumps({
|
|
"continue": True,
|
|
"output": f"⚠️ Hook error: {e}"
|
|
}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|