#!/usr/bin/env python3 """ Manifest diffing and changelog generation between workflow versions. Compares project_manifest.json snapshots to show: - Added entities (pages, components, API endpoints) - Removed entities - Modified entities (status changes, path changes) - Dependency changes """ import argparse import json import os import sys from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Set, Tuple, Any # Try to import yaml try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False # ============================================================================ # File Helpers # ============================================================================ def load_json(filepath: str) -> dict: """Load JSON file.""" if not os.path.exists(filepath): return {} with open(filepath, 'r') as f: return json.load(f) def load_yaml(filepath: str) -> dict: """Load YAML file.""" if not os.path.exists(filepath): return {} with open(filepath, 'r') as f: content = f.read() if not content.strip(): return {} if HAS_YAML: return yaml.safe_load(content) or {} return {} # ============================================================================ # Path Helpers # ============================================================================ def get_workflow_dir() -> Path: return Path('.workflow') def get_version_dir(version: str) -> Path: return get_workflow_dir() / 'versions' / version def get_snapshot_path(version: str, snapshot_type: str) -> Path: """Get path to manifest snapshot for a version.""" return get_version_dir(version) / f'snapshot_{snapshot_type}' / 'manifest.json' def get_current_manifest_path() -> Path: return Path('project_manifest.json') def get_versions_list() -> List[str]: """Get list of all versions.""" versions_dir = get_workflow_dir() / 'versions' if not versions_dir.exists(): return [] return sorted([d.name for d in versions_dir.iterdir() if d.is_dir()]) # ============================================================================ # Entity Extraction # ============================================================================ def extract_entities(manifest: dict) -> Dict[str, Dict[str, Any]]: """ Extract all entities from manifest into a flat dict keyed by ID. Returns dict like: { "page_home": {"type": "page", "name": "Home", "status": "APPROVED", ...}, "component_Button": {"type": "component", "name": "Button", ...}, ... } """ entities = {} entity_types = manifest.get('entities', {}) for entity_type, entity_list in entity_types.items(): if not isinstance(entity_list, list): continue for entity in entity_list: entity_id = entity.get('id') if entity_id: entities[entity_id] = { 'type': entity_type.rstrip('s'), # pages -> page **entity } return entities # ============================================================================ # Diff Computation # ============================================================================ def compute_diff(before: dict, after: dict) -> dict: """ Compute the difference between two manifests. Returns: { "added": [list of added entities], "removed": [list of removed entities], "modified": [list of modified entities with changes], "unchanged": [list of unchanged entity IDs] } """ before_entities = extract_entities(before) after_entities = extract_entities(after) before_ids = set(before_entities.keys()) after_ids = set(after_entities.keys()) added_ids = after_ids - before_ids removed_ids = before_ids - after_ids common_ids = before_ids & after_ids diff = { 'added': [], 'removed': [], 'modified': [], 'unchanged': [] } # Added entities for entity_id in sorted(added_ids): entity = after_entities[entity_id] diff['added'].append({ 'id': entity_id, 'type': entity.get('type'), 'name': entity.get('name'), 'file_path': entity.get('file_path'), 'status': entity.get('status') }) # Removed entities for entity_id in sorted(removed_ids): entity = before_entities[entity_id] diff['removed'].append({ 'id': entity_id, 'type': entity.get('type'), 'name': entity.get('name'), 'file_path': entity.get('file_path'), 'status': entity.get('status') }) # Modified entities for entity_id in sorted(common_ids): before_entity = before_entities[entity_id] after_entity = after_entities[entity_id] changes = [] # Check each field for changes for field in ['name', 'file_path', 'status', 'description', 'dependencies']: before_val = before_entity.get(field) after_val = after_entity.get(field) if before_val != after_val: changes.append({ 'field': field, 'before': before_val, 'after': after_val }) if changes: diff['modified'].append({ 'id': entity_id, 'type': before_entity.get('type'), 'name': after_entity.get('name'), 'file_path': after_entity.get('file_path'), 'changes': changes }) else: diff['unchanged'].append(entity_id) return diff def compute_summary(diff: dict) -> dict: """Compute summary statistics from diff.""" return { 'total_added': len(diff['added']), 'total_removed': len(diff['removed']), 'total_modified': len(diff['modified']), 'total_unchanged': len(diff['unchanged']), 'by_type': { 'pages': { 'added': len([e for e in diff['added'] if e['type'] == 'page']), 'removed': len([e for e in diff['removed'] if e['type'] == 'page']), 'modified': len([e for e in diff['modified'] if e['type'] == 'page']) }, 'components': { 'added': len([e for e in diff['added'] if e['type'] == 'component']), 'removed': len([e for e in diff['removed'] if e['type'] == 'component']), 'modified': len([e for e in diff['modified'] if e['type'] == 'component']) }, 'api_endpoints': { 'added': len([e for e in diff['added'] if e['type'] == 'api_endpoint']), 'removed': len([e for e in diff['removed'] if e['type'] == 'api_endpoint']), 'modified': len([e for e in diff['modified'] if e['type'] == 'api_endpoint']) } } } # ============================================================================ # Display Functions # ============================================================================ def format_entity(entity: dict, prefix: str = '') -> str: """Format an entity for display.""" type_icon = { 'page': '📄', 'component': '🧩', 'api_endpoint': '🔌', 'lib': '📚', 'hook': '🪝', 'type': '📝', 'config': '⚙️' }.get(entity.get('type', ''), '•') name = entity.get('name', entity.get('id', 'Unknown')) file_path = entity.get('file_path', '') return f"{prefix}{type_icon} {name} ({file_path})" def display_diff(diff: dict, summary: dict, from_version: str, to_version: str): """Display diff in a formatted way.""" print() print("╔" + "═" * 70 + "╗") print("║" + f" MANIFEST DIFF: {from_version} → {to_version}".ljust(70) + "║") print("╠" + "═" * 70 + "╣") # Summary print("║" + " SUMMARY".ljust(70) + "║") print("║" + f" + Added: {summary['total_added']}".ljust(70) + "║") print("║" + f" ~ Modified: {summary['total_modified']}".ljust(70) + "║") print("║" + f" - Removed: {summary['total_removed']}".ljust(70) + "║") print("║" + f" = Unchanged: {summary['total_unchanged']}".ljust(70) + "║") # By type print("╠" + "═" * 70 + "╣") print("║" + " BY TYPE".ljust(70) + "║") for type_name, counts in summary['by_type'].items(): changes = [] if counts['added'] > 0: changes.append(f"+{counts['added']}") if counts['modified'] > 0: changes.append(f"~{counts['modified']}") if counts['removed'] > 0: changes.append(f"-{counts['removed']}") if changes: print("║" + f" {type_name}: {' '.join(changes)}".ljust(70) + "║") # Added if diff['added']: print("╠" + "═" * 70 + "╣") print("║" + " ➕ ADDED".ljust(70) + "║") for entity in diff['added']: line = format_entity(entity, ' + ') print("║" + line[:70].ljust(70) + "║") # Modified if diff['modified']: print("╠" + "═" * 70 + "╣") print("║" + " 📝 MODIFIED".ljust(70) + "║") for entity in diff['modified']: line = format_entity(entity, ' ~ ') print("║" + line[:70].ljust(70) + "║") for change in entity['changes']: field = change['field'] before = str(change['before'])[:20] if change['before'] else '(none)' after = str(change['after'])[:20] if change['after'] else '(none)' change_line = f" {field}: {before} → {after}" print("║" + change_line[:70].ljust(70) + "║") # Removed if diff['removed']: print("╠" + "═" * 70 + "╣") print("║" + " ➖ REMOVED".ljust(70) + "║") for entity in diff['removed']: line = format_entity(entity, ' - ') print("║" + line[:70].ljust(70) + "║") print("╚" + "═" * 70 + "╝") def display_changelog(version: str, session: dict, diff: dict, summary: dict): """Display changelog for a single version.""" print() print("╔" + "═" * 70 + "╗") print("║" + f" CHANGELOG: {version}".ljust(70) + "║") print("╠" + "═" * 70 + "╣") print("║" + f" Feature: {session.get('feature', 'Unknown')[:55]}".ljust(70) + "║") print("║" + f" Status: {session.get('status', 'unknown')}".ljust(70) + "║") if session.get('started_at'): print("║" + f" Started: {session['started_at'][:19]}".ljust(70) + "║") if session.get('completed_at'): print("║" + f" Completed: {session['completed_at'][:19]}".ljust(70) + "║") print("╠" + "═" * 70 + "╣") print("║" + " CHANGES".ljust(70) + "║") if not diff['added'] and not diff['modified'] and not diff['removed']: print("║" + " No entity changes".ljust(70) + "║") else: for entity in diff['added']: line = f" + Added {entity['type']}: {entity['name']}" print("║" + line[:70].ljust(70) + "║") for entity in diff['modified']: line = f" ~ Modified {entity['type']}: {entity['name']}" print("║" + line[:70].ljust(70) + "║") for entity in diff['removed']: line = f" - Removed {entity['type']}: {entity['name']}" print("║" + line[:70].ljust(70) + "║") print("╚" + "═" * 70 + "╝") def output_json(data: dict): """Output data as JSON.""" print(json.dumps(data, indent=2)) # ============================================================================ # Commands # ============================================================================ def diff_versions(version1: str, version2: str, output_format: str = 'text') -> int: """Diff two specific versions.""" # Load snapshots before_path = get_snapshot_path(version1, 'after') if not before_path.exists(): before_path = get_snapshot_path(version1, 'before') after_path = get_snapshot_path(version2, 'after') if not after_path.exists(): after_path = get_snapshot_path(version2, 'before') if not before_path.exists(): print(f"Error: No snapshot found for version {version1}") return 1 if not after_path.exists(): print(f"Error: No snapshot found for version {version2}") return 1 before = load_json(str(before_path)) after = load_json(str(after_path)) diff = compute_diff(before, after) summary = compute_summary(diff) if output_format == 'json': output_json({ 'from_version': version1, 'to_version': version2, 'diff': diff, 'summary': summary }) else: display_diff(diff, summary, version1, version2) return 0 def diff_with_current(version: str, output_format: str = 'text') -> int: """Diff a version with current manifest.""" # Load version snapshot snapshot_path = get_snapshot_path(version, 'before') if not snapshot_path.exists(): print(f"Error: No snapshot found for version {version}") return 1 before = load_json(str(snapshot_path)) # Load current manifest current_path = get_current_manifest_path() if not current_path.exists(): print("Error: No current manifest found") return 1 after = load_json(str(current_path)) diff = compute_diff(before, after) summary = compute_summary(diff) if output_format == 'json': output_json({ 'from_version': version, 'to_version': 'current', 'diff': diff, 'summary': summary }) else: display_diff(diff, summary, version, 'current') return 0 def show_changelog(version: str = None, output_format: str = 'text') -> int: """Show changelog for a version or all versions.""" versions = get_versions_list() if not versions: print("No workflow versions found.") return 1 if version: versions = [v for v in versions if v == version] if not versions: print(f"Version {version} not found.") return 1 for i, v in enumerate(versions): # Load session info session_path = get_version_dir(v) / 'session.yml' session = load_yaml(str(session_path)) if session_path.exists() else {} # Get before/after snapshots before_path = get_snapshot_path(v, 'before') after_path = get_snapshot_path(v, 'after') before = load_json(str(before_path)) if before_path.exists() else {} after = load_json(str(after_path)) if after_path.exists() else {} if not after: after = before # Use before if no after exists diff = compute_diff(before, after) summary = compute_summary(diff) if output_format == 'json': output_json({ 'version': v, 'session': session, 'diff': diff, 'summary': summary }) else: display_changelog(v, session, diff, summary) return 0 # ============================================================================ # CLI Interface # ============================================================================ def main(): parser = argparse.ArgumentParser(description="Manifest diffing and changelog generation") subparsers = parser.add_subparsers(dest='command', help='Commands') # diff command diff_parser = subparsers.add_parser('diff', help='Diff two versions') diff_parser.add_argument('version1', help='First version') diff_parser.add_argument('version2', nargs='?', help='Second version (or "current")') diff_parser.add_argument('--json', action='store_true', help='Output as JSON') # changelog command changelog_parser = subparsers.add_parser('changelog', help='Show version changelog') changelog_parser.add_argument('version', nargs='?', help='Specific version (or all)') changelog_parser.add_argument('--json', action='store_true', help='Output as JSON') # versions command subparsers.add_parser('versions', help='List all versions') args = parser.parse_args() if args.command == 'diff': output_format = 'json' if args.json else 'text' if args.version2: if args.version2 == 'current': sys.exit(diff_with_current(args.version1, output_format)) else: sys.exit(diff_versions(args.version1, args.version2, output_format)) else: # Diff with current by default sys.exit(diff_with_current(args.version1, output_format)) elif args.command == 'changelog': output_format = 'json' if args.json else 'text' sys.exit(show_changelog(args.version, output_format)) elif args.command == 'versions': versions = get_versions_list() if versions: print("\nAvailable versions:") for v in versions: print(f" - {v}") else: print("No versions found.") else: parser.print_help() if __name__ == "__main__": main()