TradingAgents/.claude/hooks/validate_install_manifest.py

241 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Validate and Auto-Update Install Manifest - Pre-commit Hook
Ensures install_manifest.json is BIDIRECTIONALLY SYNCED with source directories.
AUTOMATICALLY UPDATES the manifest when files are added OR removed.
Scans:
- hooks/*.py → manifest components.hooks.files
- lib/*.py → manifest components.lib.files
- agents/*.md → manifest components.agents.files
- commands/*.md → manifest components.commands.files (excludes archive/)
- scripts/*.py → manifest components.scripts.files
- config/*.json → manifest components.config.files
- templates/*.json, *.template → manifest components.templates.files
Usage:
python3 validate_install_manifest.py [--check-only]
Flags:
--check-only Only validate, don't auto-update (for CI)
Exit Codes:
0 - Manifest is in sync (or was auto-updated)
1 - Check-only mode and files are out of sync
"""
import json
import sys
from pathlib import Path
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()
def scan_source_files(plugin_dir: Path) -> dict:
"""Scan source directories and return files by component.
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, excludes archive/
("scripts", "*.py", "scripts", False),
("config", "*.json", "config", False),
("templates", "*.json", "templates", False),
("templates", "*.template", "templates", False), # .env template
("skills", "*.md", "skills", True), # Recursive - includes docs/, examples/, templates/
]
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 (supports recursive subdirectories)
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 (for multiple patterns on same dir)
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) -> tuple[bool, list[str], list[str]]:
"""Bidirectionally sync manifest with scanned files.
Returns:
Tuple of (was_updated, list of added files, list of removed files)
"""
# Load existing manifest
manifest = json.loads(manifest_path.read_text())
added = []
removed = []
for component_name, scanned_files in scanned.items():
if component_name not in manifest.get("components", {}):
continue
existing = set(manifest["components"][component_name].get("files", []))
scanned_set = set(scanned_files)
# Find new files (in source but not in manifest)
new_files = scanned_set - existing
if new_files:
added.extend(new_files)
# Find removed files (in manifest but not in source)
deleted_files = existing - scanned_set
if deleted_files:
removed.extend(deleted_files)
# Update manifest to match source exactly
if new_files or deleted_files:
manifest["components"][component_name]["files"] = sorted(scanned_files)
if added or removed:
# Write updated manifest
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
return True, added, removed
return False, [], []
def validate_manifest(check_only: bool = False) -> tuple[bool, list[str], list[str]]:
"""Validate and optionally update manifest.
Args:
check_only: If True, only validate without updating
Returns:
Tuple of (success, list of missing files, list of orphan files)
"""
project_root = get_project_root()
plugin_dir = project_root / "plugins" / "autonomous-dev"
manifest_path = plugin_dir / "config" / "install_manifest.json"
if not manifest_path.exists():
return False, ["install_manifest.json not found"], []
# Scan source files
scanned = scan_source_files(plugin_dir)
# Load manifest and compare
try:
manifest = json.loads(manifest_path.read_text())
except json.JSONDecodeError as e:
return False, [f"Invalid JSON in manifest: {e}"], []
# Find differences
missing = [] # In source but not in manifest
orphan = [] # In manifest but not in source
for component_name, scanned_files in scanned.items():
if component_name not in manifest.get("components", {}):
continue
existing = set(manifest["components"][component_name].get("files", []))
scanned_set = set(scanned_files)
# Files that need to be added
for f in scanned_set - existing:
missing.append(f)
# Files that need to be removed
for f in existing - scanned_set:
orphan.append(f)
if not missing and not orphan:
return True, [], []
if check_only:
return False, missing, orphan
# Auto-sync manifest
updated, added, removed = sync_manifest(manifest_path, scanned)
if updated:
return True, added, removed
return True, [], []
def main() -> int:
"""Main entry point."""
check_only = "--check-only" in sys.argv
success, missing_or_added, orphan_or_removed = validate_manifest(check_only=check_only)
if success:
if missing_or_added or orphan_or_removed:
total_changes = len(missing_or_added) + len(orphan_or_removed)
print(f"✅ Auto-synced install_manifest.json ({total_changes} changes)")
if missing_or_added:
print(f"\n Added ({len(missing_or_added)}):")
for f in sorted(missing_or_added):
print(f" + {f}")
if orphan_or_removed:
print(f"\n Removed ({len(orphan_or_removed)}):")
for f in sorted(orphan_or_removed):
print(f" - {f}")
print("")
print("Manifest updated. Run: git add plugins/autonomous-dev/config/install_manifest.json")
else:
print("✅ install_manifest.json is in sync")
return 0
else:
print("❌ install_manifest.json is OUT OF SYNC!")
print("")
if missing_or_added:
print(f"Missing from manifest ({len(missing_or_added)}):")
for f in sorted(missing_or_added):
print(f" + {f}")
if orphan_or_removed:
print(f"\nOrphan entries (files deleted) ({len(orphan_or_removed)}):")
for f in sorted(orphan_or_removed):
print(f" - {f}")
if check_only:
print("")
print("Run without --check-only to auto-sync")
return 1
if __name__ == "__main__":
sys.exit(main())