TradingAgents/.claude/hooks/unified_manifest_sync.py

346 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Unified Manifest Sync Hook - Dispatcher for PreCommit Manifest Validation
Consolidates PreCommit manifest validation hooks:
- validate_install_manifest.py (install manifest sync)
- validate_settings_hooks.py (settings template validation)
Hook: PreCommit (runs before git commit completes)
Environment Variables (opt-in/opt-out):
VALIDATE_MANIFEST=true/false (default: true)
VALIDATE_SETTINGS=true/false (default: true)
AUTO_UPDATE_MANIFEST=true/false (default: true)
Exit codes:
0: All validations passed (or were auto-updated)
1: Validation failed (blocks commit)
Usage:
# As PreCommit hook (automatic)
python unified_manifest_sync.py
# Check-only mode (no auto-update)
AUTO_UPDATE_MANIFEST=false python unified_manifest_sync.py
"""
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Tuple
# ============================================================================
# Configuration
# ============================================================================
import os
# Check configuration from environment
VALIDATE_MANIFEST = os.environ.get("VALIDATE_MANIFEST", "true").lower() == "true"
VALIDATE_SETTINGS = os.environ.get("VALIDATE_SETTINGS", "true").lower() == "true"
AUTO_UPDATE_MANIFEST = os.environ.get("AUTO_UPDATE_MANIFEST", "true").lower() == "true"
# ============================================================================
# Utilities
# ============================================================================
def get_project_root() -> Path:
"""Find project root by looking for .git directory."""
current = Path.cwd()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return Path.cwd()
# ============================================================================
# Install Manifest Validation
# ============================================================================
def scan_source_files(plugin_dir: Path) -> Dict[str, List[str]]:
"""
Scan source directories and return files by component.
Args:
plugin_dir: Path to plugin directory
Returns:
Dict mapping component name to list of file paths
"""
components = {}
# Define what to scan: (directory, pattern, component_name, recursive)
scans = [
("hooks", "*.py", "hooks", False),
("lib", "*.py", "lib", False),
("agents", "*.md", "agents", False),
("commands", "*.md", "commands", False), # Top level only
("scripts", "*.py", "scripts", False),
("config", "*.json", "config", False),
("templates", "*.json", "templates", False),
("templates", "*.template", "templates", False),
("skills", "*.md", "skills", True), # Recursive
]
for dir_name, pattern, component_name, recursive in scans:
source_dir = plugin_dir / dir_name
if not source_dir.exists():
continue
files = []
glob_method = source_dir.rglob if recursive else source_dir.glob
for f in glob_method(pattern):
if not f.is_file():
continue
# Skip pycache, test files
if "__pycache__" in str(f):
continue
if f.name.startswith("test_"):
continue
# Build manifest path
relative_to_source = f.relative_to(source_dir)
relative = f"plugins/autonomous-dev/{dir_name}/{relative_to_source}"
files.append(relative)
# Extend existing component files
if component_name in components:
components[component_name] = sorted(set(components[component_name] + files))
else:
components[component_name] = sorted(files)
return components
def sync_manifest(manifest_path: Path, scanned: Dict[str, List[str]]) -> Tuple[bool, List[str], List[str]]:
"""
Bidirectionally sync manifest with scanned files.
Args:
manifest_path: Path to install_manifest.json
scanned: Scanned files by component
Returns:
Tuple of (was_updated, list of added files, list of removed files)
"""
if not manifest_path.exists():
return False, [], []
try:
manifest = json.loads(manifest_path.read_text())
except json.JSONDecodeError:
return False, [], []
components_config = manifest.get("components", {})
added_files = []
removed_files = []
was_updated = False
for component_name, scanned_files in scanned.items():
if component_name not in components_config:
continue
manifest_files = components_config[component_name].get("files", [])
# Find added files (in scanned but not in manifest)
for f in scanned_files:
if f not in manifest_files:
added_files.append(f)
manifest_files.append(f)
was_updated = True
# Find removed files (in manifest but not in scanned)
for f in list(manifest_files):
if f not in scanned_files:
removed_files.append(f)
manifest_files.remove(f)
was_updated = True
# Update manifest
components_config[component_name]["files"] = sorted(manifest_files)
# Write updated manifest
if was_updated and AUTO_UPDATE_MANIFEST:
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
return was_updated, added_files, removed_files
def validate_install_manifest() -> Tuple[bool, str]:
"""
Validate install manifest is in sync with source files.
Returns:
Tuple of (success, error_message)
"""
if not VALIDATE_MANIFEST:
return True, ""
project_root = get_project_root()
plugin_dir = project_root / "plugins" / "autonomous-dev"
manifest_path = plugin_dir / "install_manifest.json"
if not manifest_path.exists():
return True, "" # No manifest to validate
# Scan source files
scanned = scan_source_files(plugin_dir)
# Sync manifest
was_updated, added, removed = sync_manifest(manifest_path, scanned)
if was_updated:
if AUTO_UPDATE_MANIFEST:
# Auto-updated successfully
msg = f"Install manifest auto-updated:\n"
if added:
msg += f" Added: {len(added)} files\n"
if removed:
msg += f" Removed: {len(removed)} files\n"
msg += " (Changes staged automatically)\n"
return True, msg
else:
# Check-only mode - report drift
msg = f"Install manifest out of sync:\n"
if added:
msg += f" Missing: {len(added)} files\n"
for f in added[:5]: # Show first 5
msg += f" + {f}\n"
if removed:
msg += f" Orphaned: {len(removed)} files\n"
for f in removed[:5]:
msg += f" - {f}\n"
msg += " Run with AUTO_UPDATE_MANIFEST=true to fix\n"
return False, msg
return True, ""
# ============================================================================
# Settings Template Validation
# ============================================================================
def extract_hook_files(settings: Dict) -> List[str]:
"""
Extract hook file names from settings template.
Args:
settings: Settings template dictionary
Returns:
List of hook filenames
"""
hooks = []
hooks_config = settings.get("hooks", {})
for lifecycle, matchers in hooks_config.items():
if not isinstance(matchers, list):
continue
for matcher in matchers:
if not isinstance(matcher, dict):
continue
for hook in matcher.get("hooks", []):
if not isinstance(hook, dict):
continue
command = hook.get("command", "")
# Extract hook filename from command
match = re.search(r'hooks/([a-z_]+\.py)', command)
if match:
hooks.append(match.group(1))
return hooks
def validate_settings_hooks() -> Tuple[bool, str]:
"""
Validate all hooks in settings template exist.
Returns:
Tuple of (success, error_message)
"""
if not VALIDATE_SETTINGS:
return True, ""
project_root = get_project_root()
plugin_dir = project_root / "plugins" / "autonomous-dev"
# Load settings template
template_path = plugin_dir / "config" / "global_settings_template.json"
if not template_path.exists():
return True, ""
try:
settings = json.loads(template_path.read_text())
except json.JSONDecodeError as e:
return False, f"Invalid JSON in settings template: {e}"
# Extract referenced hooks
referenced_hooks = extract_hook_files(settings)
if not referenced_hooks:
return True, ""
# Check each hook exists
hooks_dir = plugin_dir / "hooks"
missing = []
for hook_file in referenced_hooks:
hook_path = hooks_dir / hook_file
if not hook_path.exists():
missing.append(hook_file)
if missing:
msg = f"Settings template references missing hooks:\n"
for h in missing:
msg += f" - {h}\n"
return False, msg
return True, ""
# ============================================================================
# Main Hook Entry Point
# ============================================================================
def main() -> int:
"""
Main hook entry point.
Runs all validations and reports results.
Returns:
0 if all validations passed, 1 if any failed
"""
all_passed = True
messages = []
# Validate install manifest
manifest_passed, manifest_msg = validate_install_manifest()
if not manifest_passed:
all_passed = False
messages.append(f"[FAIL] Install Manifest:\n{manifest_msg}")
elif manifest_msg:
messages.append(f"[INFO] Install Manifest:\n{manifest_msg}")
# Validate settings hooks
settings_passed, settings_msg = validate_settings_hooks()
if not settings_passed:
all_passed = False
messages.append(f"[FAIL] Settings Hooks:\n{settings_msg}")
# Output results
if messages:
for msg in messages:
print(msg, file=sys.stderr if not all_passed else sys.stdout)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())