#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = [] # /// import hashlib import json import subprocess import sys from datetime import datetime, timedelta from pathlib import Path from typing import Any # Simple file validation cache to prevent redundant work validation_cache = {} CACHE_TTL = timedelta(minutes=5) def get_file_hash(file_path: str) -> str | None: """Generate file hash for cache key""" try: path = Path(file_path) if not path.exists(): return None content = path.read_text(encoding="utf-8") mtime = path.stat().st_mtime return hashlib.md5(f"{content}{mtime}".encode()).hexdigest() except Exception: return None def is_cached_valid(file_path: str) -> dict[str, Any] | None: """Check if file was recently validated""" file_hash = get_file_hash(file_path) if not file_hash: return None cache_key = f"{file_path}:{file_hash}" cached = validation_cache.get(cache_key) if cached and datetime.now() - cached["timestamp"] < CACHE_TTL: return cached["result"] return None def cache_result(file_path: str, result: dict[str, Any]): """Cache validation result""" file_hash = get_file_hash(file_path) if not file_hash: return cache_key = f"{file_path}:{file_hash}" validation_cache[cache_key] = {"result": result, "timestamp": datetime.now()} def should_validate_file(file_path: str, project_type: str) -> bool: """Check if file should be validated""" if not file_path: return False # Skip non-existent files if not Path(file_path).exists(): return False # Get file extension ext = Path(file_path).suffix # Check based on project type if project_type == "javascript": return ext in [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"] elif project_type == "python": return ext in [".py", ".pyi"] elif project_type == "rust": return ext in [".rs"] elif project_type == "go": return ext in [".go"] # For unknown project types, try to validate common code files return ext in [".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".go"] def detect_package_manager() -> str: """Detect which package manager to use based on project files""" project_root = Path.cwd() # Check for lock files in order of preference if (project_root / "pnpm-lock.yaml").exists(): return "pnpm" elif (project_root / "yarn.lock").exists(): return "yarn" elif (project_root / "package-lock.json").exists(): return "npm" # Fallback to npm if no lock file found return "npm" def detect_project_type() -> str: """Detect project type based on files and dependencies""" project_root = Path.cwd() # Check for Python files if (project_root / "pyproject.toml").exists() or ( project_root / "requirements.txt" ).exists(): return "python" # Check for Rust files if (project_root / "Cargo.toml").exists(): return "rust" # Check for package.json (JavaScript/TypeScript) if (project_root / "package.json").exists(): return "javascript" # Check for Go files if (project_root / "go.mod").exists(): return "go" return "unknown" def get_available_linters(project_type: str) -> list: """Get available linting tools for the project""" linters = [] project_root = Path.cwd() if project_type == "python": # Check for Python linters if subprocess.run(["which", "ruff"], capture_output=True).returncode == 0: linters.append(("ruff", ["ruff", "check", "--fix"])) if subprocess.run(["which", "black"], capture_output=True).returncode == 0: linters.append(("black", ["black", "."])) if subprocess.run(["which", "flake8"], capture_output=True).returncode == 0: linters.append(("flake8", ["flake8"])) if subprocess.run(["which", "pylint"], capture_output=True).returncode == 0: linters.append(("pylint", ["pylint"])) elif project_type == "javascript": package_manager = detect_package_manager() # Check package.json for available scripts and dependencies package_json_path = project_root / "package.json" if package_json_path.exists(): try: with open(package_json_path) as f: package_data = json.load(f) scripts = package_data.get("scripts", {}) deps = { **package_data.get("dependencies", {}), **package_data.get("devDependencies", {}), } # Check for common linting scripts if "lint" in scripts: linters.append(("lint", [package_manager, "run", "lint"])) if "lint:fix" in scripts: linters.append(("lint:fix", [package_manager, "run", "lint:fix"])) # Check for Biome if "biome" in scripts or "@biomejs/biome" in deps: linters.append( ("biome", [package_manager, "biome", "check", "--apply"]) ) # Check for ESLint if "eslint" in deps: linters.append(("eslint", [package_manager, "run", "lint"])) # Check for Prettier if "prettier" in deps: linters.append(("prettier", [package_manager, "run", "format"])) except (json.JSONDecodeError, FileNotFoundError): pass elif project_type == "rust": # Check for Rust tools if subprocess.run(["which", "cargo"], capture_output=True).returncode == 0: linters.append(("clippy", ["cargo", "clippy", "--fix", "--allow-dirty"])) linters.append(("fmt", ["cargo", "fmt"])) elif project_type == "go": # Check for Go tools if subprocess.run(["which", "go"], capture_output=True).returncode == 0: linters.append(("fmt", ["go", "fmt", "./..."])) linters.append(("vet", ["go", "vet", "./..."])) if ( subprocess.run(["which", "golangci-lint"], capture_output=True).returncode == 0 ): linters.append(("golangci-lint", ["golangci-lint", "run", "--fix"])) return linters def get_available_type_checkers(project_type: str) -> list: """Get available type checking tools for the project""" type_checkers = [] project_root = Path.cwd() if project_type == "python": if subprocess.run(["which", "mypy"], capture_output=True).returncode == 0: type_checkers.append(("mypy", ["mypy", "."])) if subprocess.run(["which", "pyright"], capture_output=True).returncode == 0: type_checkers.append(("pyright", ["pyright"])) elif project_type == "javascript": package_manager = detect_package_manager() package_json_path = project_root / "package.json" if package_json_path.exists(): try: with open(package_json_path) as f: package_data = json.load(f) scripts = package_data.get("scripts", {}) deps = { **package_data.get("dependencies", {}), **package_data.get("devDependencies", {}), } # Check for TypeScript if "typecheck" in scripts: type_checkers.append( ("typecheck", [package_manager, "run", "typecheck"]) ) elif "typescript" in deps: type_checkers.append(("tsc", [package_manager, "tsc", "--noEmit"])) except (json.JSONDecodeError, FileNotFoundError): pass elif project_type == "rust": # Rust has built-in type checking via cargo check if subprocess.run(["which", "cargo"], capture_output=True).returncode == 0: type_checkers.append(("check", ["cargo", "check"])) elif project_type == "go": # Go has built-in type checking via go build if subprocess.run(["which", "go"], capture_output=True).returncode == 0: type_checkers.append(("build", ["go", "build", "./..."])) return type_checkers def run_linting_checks(file_path: str, project_type: str) -> list: """Run all available linting checks""" results = [] linters = get_available_linters(project_type) if not linters: return [ { "success": True, "message": "ℹ️ No linters available, skipping checks", "output": "", } ] for linter_name, linter_cmd in linters: try: # For file-specific linters, add the file path if linter_name in ["ruff", "biome"] and file_path: cmd = linter_cmd + [file_path] else: cmd = linter_cmd result = subprocess.run(cmd, capture_output=True, text=True, check=True) results.append( { "success": True, "message": f'✅ {linter_name} check passed for {Path(file_path).name if file_path else "project"}', "output": result.stdout, "linter": linter_name, } ) except subprocess.CalledProcessError as error: error_output = error.stdout or error.stderr or str(error) results.append( { "success": False, "message": f'❌ {linter_name} found issues in {Path(file_path).name if file_path else "project"}', "output": error_output, "fix": f'Run: {" ".join(cmd)}', "linter": linter_name, } ) except FileNotFoundError: results.append( { "success": True, "message": f"ℹ️ {linter_name} not available, skipping check", "output": "", "linter": linter_name, } ) return results def run_type_checks(project_type: str) -> list: """Run all available type checking""" results = [] type_checkers = get_available_type_checkers(project_type) if not type_checkers: return [ { "success": True, "message": "ℹ️ No type checkers available, skipping checks", "output": "", } ] for checker_name, checker_cmd in type_checkers: try: result = subprocess.run( checker_cmd, capture_output=True, text=True, check=True ) results.append( { "success": True, "message": f"✅ {checker_name} type check passed", "output": result.stdout, "checker": checker_name, } ) except subprocess.CalledProcessError as error: error_output = error.stdout or error.stderr or str(error) results.append( { "success": False, "message": f"❌ {checker_name} type check failed", "output": error_output, "fix": f'Run: {" ".join(checker_cmd)}', "checker": checker_name, } ) except FileNotFoundError: results.append( { "success": True, "message": f"ℹ️ {checker_name} not available, skipping check", "output": "", "checker": checker_name, } ) return results def validate_file(file_path: str) -> dict[str, Any]: """Validate a single file""" # Check cache first cached = is_cached_valid(file_path) if cached: return cached # Detect project type project_type = detect_project_type() # Check if file should be validated if not should_validate_file(file_path, project_type): result = { "approve": True, "message": f"ℹ️ Skipped {Path(file_path).name} (not a supported file type for {project_type} project)", } return result # Run linting checks lint_results = run_linting_checks(file_path, project_type) # Run type checking (project-wide) type_results = run_type_checks(project_type) # Combine all results all_results = lint_results + type_results all_passed = all(result["success"] for result in all_results) if all_passed: successful_tools = [ r.get("linter", r.get("checker", "tool")) for r in all_results if r["success"] ] tools_used = ", ".join(filter(None, successful_tools)) result = { "approve": True, "message": f"✅ All checks passed for {Path(file_path).name}" + (f" ({tools_used})" if tools_used else ""), } else: issues = [] fixes = [] for check_result in all_results: if not check_result["success"]: issues.append(check_result["message"]) if "fix" in check_result: fixes.append(check_result["fix"]) message_parts = ["❌ Validation failed:"] + issues if fixes: message_parts.extend(["", "🔧 Fixes:"] + fixes) result = {"approve": False, "message": "\n".join(message_parts)} # Cache result cache_result(file_path, result) return result def main(): """Main execution""" try: input_data = json.load(sys.stdin) # Extract file path from tool input tool_input = input_data.get("tool_input", {}) file_path = tool_input.get("file_path") if not file_path: # No file path provided, approve by default result = { "approve": True, "message": "ℹ️ No file path provided, skipping validation", } else: # Show user-friendly message that linter is running file_name = Path(file_path).name if file_path else "file" print(f"🔍 Running linter on {file_name}...", file=sys.stderr) result = validate_file(file_path) # Show result to user if result.get("approve", True): print(f"✨ Linting complete for {file_name}", file=sys.stderr) else: print( f"🔧 Linter found issues in {file_name} (see details above)", file=sys.stderr, ) # Log the linting activity try: # Ensure log directory exists log_dir = Path.cwd() / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / "universal_linter.json" # Read existing log data or initialize empty list if log_path.exists(): with open(log_path) as f: try: log_data = json.load(f) except (json.JSONDecodeError, ValueError): log_data = [] else: log_data = [] # Create log entry with relevant data log_entry = { "file_path": file_path, "project_type": detect_project_type() if file_path else "unknown", "result": result.get("approve", True), "message": result.get("message", ""), "tool_input": tool_input, "session_id": input_data.get("session_id", "unknown"), } # Add timestamp to the log entry timestamp = datetime.now().strftime("%b %d, %I:%M%p").lower() log_entry["timestamp"] = timestamp # Append new data log_data.append(log_entry) # Write back to file with formatting with open(log_path, "w") as f: json.dump(log_data, f, indent=2) except Exception: # Don't let logging errors break the hook pass print(json.dumps(result)) except Exception as error: print( json.dumps({"approve": True, "message": f"Universal linter error: {error}"}) ) if __name__ == "__main__": main()