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>
296 lines
8.6 KiB
Python
296 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
LWC LSP Validation Hook
|
||
=======================
|
||
|
||
This PostToolUse hook validates LWC JavaScript files (.js) after Write/Edit
|
||
operations using the LWC Language Server (@salesforce/lwc-language-server).
|
||
|
||
Behavior (Auto-fix loop):
|
||
- Outputs errors to Claude so it can automatically fix them
|
||
- Repeats until valid or max attempts reached
|
||
- Complements existing 165-point SLDS 2 validation
|
||
|
||
Prerequisites:
|
||
- npm install -g @salesforce/lwc-language-server
|
||
|
||
Usage:
|
||
Triggered automatically by hooks.json configuration
|
||
Input: JSON from stdin with tool_name and tool_input
|
||
Output: Diagnostic messages to stdout (or empty if valid)
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
# Add shared lsp-engine to path
|
||
# The installer places lsp-engine at ~/.claude/lsp-engine/ but in a dev
|
||
# repo checkout it lives at <repo>/shared/lsp-engine/. Try both.
|
||
SCRIPT_DIR = Path(__file__).parent
|
||
PLUGIN_ROOT = SCRIPT_DIR.parent.parent
|
||
_LSP_CANDIDATES = [
|
||
Path.home() / ".claude" / "lsp-engine", # Installed path
|
||
PLUGIN_ROOT.parent.parent / "shared" / "lsp-engine", # Dev repo path
|
||
]
|
||
LSP_ENGINE_PATH = next((p for p in _LSP_CANDIDATES if p.is_dir()), _LSP_CANDIDATES[0])
|
||
sys.path.insert(0, str(LSP_ENGINE_PATH))
|
||
|
||
# Track validation attempts to prevent infinite loops
|
||
ATTEMPT_FILE = Path(tempfile.gettempdir()) / "lwc_lsp_attempts.json"
|
||
MAX_ATTEMPTS = 3
|
||
|
||
# LWC file extensions
|
||
LWC_EXTENSIONS = {".js"}
|
||
|
||
# LSP Severity levels
|
||
SEVERITY_ERROR = 1
|
||
SEVERITY_WARNING = 2
|
||
SEVERITY_INFO = 3
|
||
SEVERITY_HINT = 4
|
||
|
||
SEVERITY_NAMES = {
|
||
SEVERITY_ERROR: "ERROR",
|
||
SEVERITY_WARNING: "WARNING",
|
||
SEVERITY_INFO: "INFO",
|
||
SEVERITY_HINT: "HINT",
|
||
}
|
||
|
||
SEVERITY_ICONS = {
|
||
SEVERITY_ERROR: "❌",
|
||
SEVERITY_WARNING: "⚠️",
|
||
SEVERITY_INFO: "ℹ️",
|
||
SEVERITY_HINT: "💡",
|
||
}
|
||
|
||
|
||
def get_attempt_count(file_path: str) -> int:
|
||
"""Get the current attempt count for a file."""
|
||
try:
|
||
if ATTEMPT_FILE.exists():
|
||
with open(ATTEMPT_FILE, "r") as f:
|
||
attempts = json.load(f)
|
||
return attempts.get(file_path, 0)
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
def increment_attempt_count(file_path: str) -> int:
|
||
"""Increment and return the attempt count for a file."""
|
||
attempts = {}
|
||
try:
|
||
if ATTEMPT_FILE.exists():
|
||
with open(ATTEMPT_FILE, "r") as f:
|
||
attempts = json.load(f)
|
||
except Exception:
|
||
pass
|
||
|
||
attempts[file_path] = attempts.get(file_path, 0) + 1
|
||
count = attempts[file_path]
|
||
|
||
try:
|
||
with open(ATTEMPT_FILE, "w") as f:
|
||
json.dump(attempts, f)
|
||
except Exception:
|
||
pass
|
||
|
||
return count
|
||
|
||
|
||
def reset_attempt_count(file_path: str):
|
||
"""Reset attempt count when validation succeeds."""
|
||
try:
|
||
if ATTEMPT_FILE.exists():
|
||
with open(ATTEMPT_FILE, "r") as f:
|
||
attempts = json.load(f)
|
||
if file_path in attempts:
|
||
del attempts[file_path]
|
||
with open(ATTEMPT_FILE, "w") as f:
|
||
json.dump(attempts, f)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def format_lwc_diagnostics(
|
||
result: Dict[str, Any],
|
||
file_path: str,
|
||
max_attempts: int = 3,
|
||
current_attempt: int = 1,
|
||
) -> str:
|
||
"""
|
||
Format LSP validation result for Claude Code hooks.
|
||
|
||
This output is designed to be understood by Claude so it can
|
||
automatically fix any issues found.
|
||
"""
|
||
# If LSP had an error
|
||
if "error" in result and result["error"]:
|
||
return f"⚠️ LWC LSP validation skipped: {result['error']}"
|
||
|
||
diagnostics = result.get("diagnostics", [])
|
||
success = result.get("success", False)
|
||
|
||
# No issues found - show success message
|
||
if success and not diagnostics:
|
||
file_name = Path(file_path).name
|
||
lines = []
|
||
lines.append(f"✅ LWC LSP Validation Passed: {file_name}")
|
||
lines.append(" • JavaScript syntax: OK")
|
||
lines.append(" • LWC decorators: OK")
|
||
lines.append(" • Import resolution: OK")
|
||
return "\n".join(lines)
|
||
|
||
# Count errors and warnings
|
||
error_count = sum(1 for d in diagnostics if d.get("severity", 1) == SEVERITY_ERROR)
|
||
warning_count = sum(1 for d in diagnostics if d.get("severity", 2) == SEVERITY_WARNING)
|
||
|
||
# Build output for Claude
|
||
lines = []
|
||
|
||
# Header
|
||
lines.append("=" * 60)
|
||
lines.append("⚡ LWC LSP VALIDATION RESULTS")
|
||
lines.append(f" File: {file_path}")
|
||
lines.append(f" Attempt: {current_attempt}/{max_attempts}")
|
||
lines.append("=" * 60)
|
||
lines.append("")
|
||
|
||
# Summary
|
||
if error_count > 0 or warning_count > 0:
|
||
lines.append(f"Found {error_count} error(s), {warning_count} warning(s)")
|
||
lines.append("")
|
||
|
||
# Diagnostics
|
||
if diagnostics:
|
||
lines.append("ISSUES TO FIX:")
|
||
lines.append("-" * 40)
|
||
for diag in diagnostics:
|
||
severity = diag.get("severity", SEVERITY_ERROR)
|
||
severity_name = SEVERITY_NAMES.get(severity, "UNKNOWN")
|
||
icon = SEVERITY_ICONS.get(severity, "❓")
|
||
|
||
message = diag.get("message", "Unknown error")
|
||
|
||
# Extract line info
|
||
range_info = diag.get("range", {})
|
||
start = range_info.get("start", {})
|
||
start_line = start.get("line", 0) + 1 # LSP is 0-indexed
|
||
|
||
source = diag.get("source", "lwc")
|
||
|
||
lines.append(f"{icon} [{severity_name}] line {start_line}: {message} (source: {source})")
|
||
lines.append("")
|
||
|
||
# Instructions for Claude
|
||
if error_count > 0:
|
||
lines.append("ACTION REQUIRED:")
|
||
lines.append("Please fix the LWC JavaScript errors above and try again.")
|
||
if current_attempt < max_attempts:
|
||
lines.append(f"(Attempt {current_attempt}/{max_attempts})")
|
||
else:
|
||
lines.append("⚠️ Maximum attempts reached. Manual review may be needed.")
|
||
|
||
lines.append("=" * 60)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def is_lwc_js_file(file_path: str) -> bool:
|
||
"""Check if file is an LWC JavaScript file."""
|
||
path = Path(file_path)
|
||
# Must be .js file in an lwc folder
|
||
if path.suffix.lower() not in LWC_EXTENSIONS:
|
||
return False
|
||
# Must be in /lwc/ folder structure
|
||
if "/lwc/" not in file_path:
|
||
return False
|
||
# Skip test files
|
||
if ".test.js" in file_path or "__tests__" in file_path:
|
||
return False
|
||
return True
|
||
|
||
|
||
def main():
|
||
"""Main hook entry point."""
|
||
# Read hook input from stdin
|
||
try:
|
||
hook_input = json.load(sys.stdin)
|
||
except json.JSONDecodeError:
|
||
# No input or invalid JSON - skip validation
|
||
sys.exit(0)
|
||
|
||
# Extract file path
|
||
tool_input = hook_input.get("tool_input", {})
|
||
file_path = tool_input.get("file_path", "")
|
||
|
||
# Only validate LWC JavaScript files
|
||
if not is_lwc_js_file(file_path):
|
||
sys.exit(0)
|
||
|
||
# Check if file exists
|
||
if not os.path.exists(file_path):
|
||
sys.exit(0)
|
||
|
||
# Track attempts
|
||
current_attempt = increment_attempt_count(file_path)
|
||
|
||
# If max attempts exceeded, skip validation to avoid infinite loop
|
||
if current_attempt > MAX_ATTEMPTS:
|
||
print(f"⚠️ LWC LSP validation: Maximum attempts ({MAX_ATTEMPTS}) exceeded for {file_path}")
|
||
print(" Manual review may be required.")
|
||
reset_attempt_count(file_path) # Reset for next edit session
|
||
sys.exit(0)
|
||
|
||
# Try to import LSP engine
|
||
try:
|
||
from lsp_client import LSPClient
|
||
except ImportError as e:
|
||
# LSP engine not available - skip validation silently
|
||
# This allows the plugin to work even without LSP
|
||
sys.exit(0)
|
||
|
||
# Check if LWC LSP wrapper exists
|
||
lwc_wrapper = LSP_ENGINE_PATH / "lwc_wrapper.sh"
|
||
if not lwc_wrapper.exists():
|
||
# LWC LSP wrapper not available - skip silently
|
||
sys.exit(0)
|
||
|
||
# Create LSP client with LWC wrapper and language ID
|
||
try:
|
||
client = LSPClient(wrapper_path=str(lwc_wrapper), language_id="javascript")
|
||
except Exception as e:
|
||
# LSP initialization error - skip silently
|
||
sys.exit(0)
|
||
|
||
# Validate the file
|
||
try:
|
||
result = client.validate_file(file_path)
|
||
except Exception as e:
|
||
# Validation error - report but don't block
|
||
print(f"⚠️ LWC LSP validation error: {e}")
|
||
sys.exit(0)
|
||
|
||
# Format output
|
||
output = format_lwc_diagnostics(
|
||
result,
|
||
file_path,
|
||
max_attempts=MAX_ATTEMPTS,
|
||
current_attempt=current_attempt
|
||
)
|
||
|
||
# If no output (validation passed), reset attempt count
|
||
if not output:
|
||
reset_attempt_count(file_path)
|
||
sys.exit(0)
|
||
|
||
# Output diagnostics for Claude
|
||
print(output)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|