claude-mpm 4.1.8__py3-none-any.whl → 4.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/INSTRUCTIONS.md +26 -1
  3. claude_mpm/agents/agents_metadata.py +57 -0
  4. claude_mpm/agents/templates/.claude-mpm/memories/README.md +17 -0
  5. claude_mpm/agents/templates/.claude-mpm/memories/engineer_memories.md +3 -0
  6. claude_mpm/agents/templates/agent-manager.json +263 -17
  7. claude_mpm/agents/templates/agentic_coder_optimizer.json +222 -0
  8. claude_mpm/agents/templates/code_analyzer.json +18 -8
  9. claude_mpm/agents/templates/engineer.json +1 -1
  10. claude_mpm/agents/templates/logs/prompts/agent_engineer_20250826_014258_728.md +39 -0
  11. claude_mpm/agents/templates/qa.json +1 -1
  12. claude_mpm/agents/templates/research.json +1 -1
  13. claude_mpm/cli/__init__.py +15 -0
  14. claude_mpm/cli/commands/__init__.py +6 -0
  15. claude_mpm/cli/commands/analyze.py +548 -0
  16. claude_mpm/cli/commands/analyze_code.py +524 -0
  17. claude_mpm/cli/commands/configure.py +78 -28
  18. claude_mpm/cli/commands/configure_tui.py +62 -60
  19. claude_mpm/cli/commands/dashboard.py +288 -0
  20. claude_mpm/cli/commands/debug.py +1386 -0
  21. claude_mpm/cli/commands/mpm_init.py +427 -0
  22. claude_mpm/cli/commands/mpm_init_handler.py +83 -0
  23. claude_mpm/cli/parsers/analyze_code_parser.py +170 -0
  24. claude_mpm/cli/parsers/analyze_parser.py +135 -0
  25. claude_mpm/cli/parsers/base_parser.py +44 -0
  26. claude_mpm/cli/parsers/dashboard_parser.py +113 -0
  27. claude_mpm/cli/parsers/debug_parser.py +319 -0
  28. claude_mpm/cli/parsers/mpm_init_parser.py +122 -0
  29. claude_mpm/constants.py +13 -1
  30. claude_mpm/core/framework_loader.py +148 -6
  31. claude_mpm/core/log_manager.py +16 -13
  32. claude_mpm/core/logger.py +1 -1
  33. claude_mpm/core/unified_agent_registry.py +1 -1
  34. claude_mpm/dashboard/.claude-mpm/socketio-instances.json +1 -0
  35. claude_mpm/dashboard/analysis_runner.py +455 -0
  36. claude_mpm/dashboard/static/built/components/activity-tree.js +2 -0
  37. claude_mpm/dashboard/static/built/components/agent-inference.js +1 -1
  38. claude_mpm/dashboard/static/built/components/code-tree.js +2 -0
  39. claude_mpm/dashboard/static/built/components/code-viewer.js +2 -0
  40. claude_mpm/dashboard/static/built/components/event-viewer.js +1 -1
  41. claude_mpm/dashboard/static/built/components/file-tool-tracker.js +1 -1
  42. claude_mpm/dashboard/static/built/components/module-viewer.js +1 -1
  43. claude_mpm/dashboard/static/built/components/session-manager.js +1 -1
  44. claude_mpm/dashboard/static/built/components/working-directory.js +1 -1
  45. claude_mpm/dashboard/static/built/dashboard.js +1 -1
  46. claude_mpm/dashboard/static/built/socket-client.js +1 -1
  47. claude_mpm/dashboard/static/css/activity.css +549 -0
  48. claude_mpm/dashboard/static/css/code-tree.css +1175 -0
  49. claude_mpm/dashboard/static/css/dashboard.css +245 -0
  50. claude_mpm/dashboard/static/dist/components/activity-tree.js +2 -0
  51. claude_mpm/dashboard/static/dist/components/code-tree.js +2 -0
  52. claude_mpm/dashboard/static/dist/components/code-viewer.js +2 -0
  53. claude_mpm/dashboard/static/dist/components/event-viewer.js +1 -1
  54. claude_mpm/dashboard/static/dist/components/session-manager.js +1 -1
  55. claude_mpm/dashboard/static/dist/components/working-directory.js +1 -1
  56. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  57. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  58. claude_mpm/dashboard/static/js/components/activity-tree.js +1338 -0
  59. claude_mpm/dashboard/static/js/components/code-tree.js +2535 -0
  60. claude_mpm/dashboard/static/js/components/code-viewer.js +480 -0
  61. claude_mpm/dashboard/static/js/components/event-viewer.js +59 -9
  62. claude_mpm/dashboard/static/js/components/session-manager.js +40 -4
  63. claude_mpm/dashboard/static/js/components/socket-manager.js +12 -0
  64. claude_mpm/dashboard/static/js/components/ui-state-manager.js +4 -0
  65. claude_mpm/dashboard/static/js/components/working-directory.js +17 -1
  66. claude_mpm/dashboard/static/js/dashboard.js +51 -0
  67. claude_mpm/dashboard/static/js/socket-client.js +465 -29
  68. claude_mpm/dashboard/templates/index.html +182 -4
  69. claude_mpm/hooks/claude_hooks/hook_handler.py +182 -5
  70. claude_mpm/hooks/claude_hooks/installer.py +386 -113
  71. claude_mpm/scripts/claude-hook-handler.sh +161 -0
  72. claude_mpm/scripts/socketio_daemon.py +121 -8
  73. claude_mpm/services/agents/deployment/agent_lifecycle_manager_refactored.py +2 -2
  74. claude_mpm/services/agents/deployment/agent_record_service.py +1 -2
  75. claude_mpm/services/agents/memory/memory_format_service.py +1 -3
  76. claude_mpm/services/cli/agent_cleanup_service.py +1 -5
  77. claude_mpm/services/cli/agent_dependency_service.py +1 -1
  78. claude_mpm/services/cli/agent_validation_service.py +3 -4
  79. claude_mpm/services/cli/dashboard_launcher.py +2 -3
  80. claude_mpm/services/cli/startup_checker.py +0 -11
  81. claude_mpm/services/core/cache_manager.py +1 -3
  82. claude_mpm/services/core/path_resolver.py +1 -4
  83. claude_mpm/services/core/service_container.py +2 -2
  84. claude_mpm/services/diagnostics/checks/instructions_check.py +1 -2
  85. claude_mpm/services/infrastructure/monitoring/__init__.py +11 -11
  86. claude_mpm/services/infrastructure/monitoring.py +11 -11
  87. claude_mpm/services/project/architecture_analyzer.py +1 -1
  88. claude_mpm/services/project/dependency_analyzer.py +4 -4
  89. claude_mpm/services/project/language_analyzer.py +3 -3
  90. claude_mpm/services/project/metrics_collector.py +3 -6
  91. claude_mpm/services/socketio/event_normalizer.py +64 -0
  92. claude_mpm/services/socketio/handlers/__init__.py +2 -0
  93. claude_mpm/services/socketio/handlers/code_analysis.py +672 -0
  94. claude_mpm/services/socketio/handlers/registry.py +2 -0
  95. claude_mpm/services/socketio/server/connection_manager.py +6 -4
  96. claude_mpm/services/socketio/server/core.py +100 -11
  97. claude_mpm/services/socketio/server/main.py +8 -2
  98. claude_mpm/services/visualization/__init__.py +19 -0
  99. claude_mpm/services/visualization/mermaid_generator.py +938 -0
  100. claude_mpm/tools/__main__.py +208 -0
  101. claude_mpm/tools/code_tree_analyzer.py +1596 -0
  102. claude_mpm/tools/code_tree_builder.py +631 -0
  103. claude_mpm/tools/code_tree_events.py +416 -0
  104. claude_mpm/tools/socketio_debug.py +671 -0
  105. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/METADATA +2 -1
  106. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/RECORD +110 -74
  107. claude_mpm/agents/schema/agent_schema.json +0 -314
  108. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/WHEEL +0 -0
  109. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/entry_points.txt +0 -0
  110. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/licenses/LICENSE +0 -0
  111. {claude_mpm-4.1.8.dist-info → claude_mpm-4.1.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Code Tree Builder
4
+ =================
5
+
6
+ WHY: Builds hierarchical file system structure with intelligent filtering
7
+ and incremental processing support for large codebases.
8
+
9
+ DESIGN DECISIONS:
10
+ - Support .gitignore patterns for filtering
11
+ - Incremental processing with resume capability
12
+ - Efficient directory traversal with progress tracking
13
+ - Cache file metadata to detect changes
14
+ """
15
+
16
+ import fnmatch
17
+ import hashlib
18
+ import json
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Set
23
+
24
+ from ..core.logging_config import get_logger
25
+
26
+
27
+ @dataclass
28
+ class FileMetadata:
29
+ """Metadata for a file in the tree."""
30
+
31
+ path: str
32
+ size: int
33
+ modified: float
34
+ hash: Optional[str] = None
35
+ language: Optional[str] = None
36
+
37
+
38
+ @dataclass
39
+ class TreeNode:
40
+ """Node in the file tree."""
41
+
42
+ name: str
43
+ path: str
44
+ type: str # 'file' or 'directory'
45
+ children: List["TreeNode"] = field(default_factory=list)
46
+ metadata: Optional[FileMetadata] = None
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ """Convert to dictionary representation."""
50
+ result = {"name": self.name, "path": self.path, "type": self.type}
51
+
52
+ if self.metadata:
53
+ result["metadata"] = {
54
+ "size": self.metadata.size,
55
+ "modified": self.metadata.modified,
56
+ "language": self.metadata.language,
57
+ }
58
+
59
+ if self.children:
60
+ result["children"] = [child.to_dict() for child in self.children]
61
+
62
+ return result
63
+
64
+
65
+ class GitignoreParser:
66
+ """Parser for .gitignore patterns.
67
+
68
+ WHY: Respecting .gitignore patterns ensures we don't analyze files
69
+ that shouldn't be included in the codebase analysis.
70
+ """
71
+
72
+ def __init__(self, root_dir: Path):
73
+ self.root_dir = root_dir
74
+ self.patterns = []
75
+ self.logger = get_logger(__name__)
76
+ self._load_patterns()
77
+
78
+ def _load_patterns(self):
79
+ """Load .gitignore patterns from file."""
80
+ gitignore_path = self.root_dir / ".gitignore"
81
+
82
+ if gitignore_path.exists():
83
+ try:
84
+ with open(gitignore_path) as f:
85
+ for line in f:
86
+ line = line.strip()
87
+ # Skip comments and empty lines
88
+ if line and not line.startswith("#"):
89
+ self.patterns.append(line)
90
+
91
+ self.logger.debug(f"Loaded {len(self.patterns)} .gitignore patterns")
92
+ except Exception as e:
93
+ self.logger.warning(f"Failed to load .gitignore: {e}")
94
+
95
+ def should_ignore(self, path: Path) -> bool:
96
+ """Check if path should be ignored based on patterns.
97
+
98
+ Args:
99
+ path: Path to check
100
+
101
+ Returns:
102
+ True if path should be ignored
103
+ """
104
+ # Get relative path from root
105
+ try:
106
+ rel_path = path.relative_to(self.root_dir)
107
+ except ValueError:
108
+ return False
109
+
110
+ rel_str = str(rel_path)
111
+
112
+ for pattern in self.patterns:
113
+ # Handle directory patterns
114
+ if pattern.endswith("/"):
115
+ if path.is_dir() and fnmatch.fnmatch(rel_str, pattern[:-1]):
116
+ return True
117
+ # Handle negation patterns
118
+ elif pattern.startswith("!"):
119
+ if fnmatch.fnmatch(rel_str, pattern[1:]):
120
+ return False
121
+ # Regular patterns
122
+ else:
123
+ if fnmatch.fnmatch(rel_str, pattern):
124
+ return True
125
+ # Also check if any parent directory matches
126
+ for parent in rel_path.parents:
127
+ if fnmatch.fnmatch(str(parent), pattern):
128
+ return True
129
+
130
+ return False
131
+
132
+
133
+ class CodeTreeBuilder:
134
+ """Builds hierarchical code tree with filtering and caching.
135
+
136
+ WHY: Efficient tree building is crucial for large codebases. This class
137
+ handles incremental processing, caching, and intelligent filtering.
138
+ """
139
+
140
+ # Default ignore patterns
141
+ DEFAULT_IGNORE = [
142
+ "__pycache__",
143
+ ".git",
144
+ "node_modules",
145
+ ".venv",
146
+ "venv",
147
+ "env",
148
+ "dist",
149
+ "build",
150
+ ".pytest_cache",
151
+ ".mypy_cache",
152
+ ".tox",
153
+ ".eggs",
154
+ "*.egg-info",
155
+ ".coverage",
156
+ "htmlcov",
157
+ ".hypothesis",
158
+ ".ruff_cache",
159
+ ".DS_Store",
160
+ "Thumbs.db",
161
+ "*.pyc",
162
+ "*.pyo",
163
+ "*.pyd",
164
+ ".Python",
165
+ "*.so",
166
+ "*.dll",
167
+ "*.dylib",
168
+ ".idea",
169
+ ".vscode",
170
+ "*.swp",
171
+ "*.swo",
172
+ "*~",
173
+ ".env",
174
+ ".env.local",
175
+ ]
176
+
177
+ # Language detection by extension
178
+ LANGUAGE_MAP = {
179
+ ".py": "python",
180
+ ".pyw": "python",
181
+ ".pyx": "python",
182
+ ".pxd": "python",
183
+ ".pyi": "python",
184
+ ".js": "javascript",
185
+ ".jsx": "javascript",
186
+ ".ts": "typescript",
187
+ ".tsx": "typescript",
188
+ ".mjs": "javascript",
189
+ ".cjs": "javascript",
190
+ ".java": "java",
191
+ ".kt": "kotlin",
192
+ ".scala": "scala",
193
+ ".go": "go",
194
+ ".rs": "rust",
195
+ ".c": "c",
196
+ ".h": "c",
197
+ ".cpp": "cpp",
198
+ ".cc": "cpp",
199
+ ".cxx": "cpp",
200
+ ".hpp": "cpp",
201
+ ".cs": "csharp",
202
+ ".rb": "ruby",
203
+ ".php": "php",
204
+ ".swift": "swift",
205
+ ".m": "objc",
206
+ ".mm": "objc",
207
+ ".r": "r",
208
+ ".R": "r",
209
+ ".lua": "lua",
210
+ ".pl": "perl",
211
+ ".pm": "perl",
212
+ ".sh": "bash",
213
+ ".bash": "bash",
214
+ ".zsh": "zsh",
215
+ ".fish": "fish",
216
+ ".ps1": "powershell",
217
+ ".psm1": "powershell",
218
+ ".vim": "vim",
219
+ ".el": "elisp",
220
+ ".clj": "clojure",
221
+ ".cljs": "clojure",
222
+ ".ex": "elixir",
223
+ ".exs": "elixir",
224
+ ".erl": "erlang",
225
+ ".hrl": "erlang",
226
+ ".fs": "fsharp",
227
+ ".fsx": "fsharp",
228
+ ".ml": "ocaml",
229
+ ".mli": "ocaml",
230
+ ".dart": "dart",
231
+ ".nim": "nim",
232
+ ".nims": "nim",
233
+ ".zig": "zig",
234
+ ".v": "v",
235
+ ".vv": "v",
236
+ ".sql": "sql",
237
+ ".md": "markdown",
238
+ ".rst": "restructuredtext",
239
+ ".tex": "latex",
240
+ ".json": "json",
241
+ ".xml": "xml",
242
+ ".yaml": "yaml",
243
+ ".yml": "yaml",
244
+ ".toml": "toml",
245
+ ".ini": "ini",
246
+ ".cfg": "ini",
247
+ ".conf": "conf",
248
+ ".dockerfile": "dockerfile",
249
+ ".Dockerfile": "dockerfile",
250
+ ".html": "html",
251
+ ".htm": "html",
252
+ ".css": "css",
253
+ ".scss": "scss",
254
+ ".sass": "sass",
255
+ ".less": "less",
256
+ }
257
+
258
+ def __init__(self, cache_dir: Optional[Path] = None):
259
+ """Initialize tree builder.
260
+
261
+ Args:
262
+ cache_dir: Directory for caching tree data
263
+ """
264
+ self.logger = get_logger(__name__)
265
+ self.cache_dir = cache_dir or Path.home() / ".claude-mpm" / "tree-cache"
266
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
267
+
268
+ # Statistics
269
+ self.stats = {
270
+ "directories_scanned": 0,
271
+ "files_found": 0,
272
+ "files_ignored": 0,
273
+ "total_size": 0,
274
+ "languages": set(),
275
+ }
276
+
277
+ def build_tree(
278
+ self,
279
+ root_path: Path,
280
+ file_extensions: Optional[List[str]] = None,
281
+ ignore_patterns: Optional[List[str]] = None,
282
+ max_depth: Optional[int] = None,
283
+ use_gitignore: bool = True,
284
+ calculate_hashes: bool = False,
285
+ progress_callback: Optional[callable] = None,
286
+ ) -> TreeNode:
287
+ """Build file tree from directory.
288
+
289
+ Args:
290
+ root_path: Root directory to scan
291
+ file_extensions: File extensions to include (None for all)
292
+ ignore_patterns: Additional ignore patterns
293
+ max_depth: Maximum directory depth to traverse
294
+ use_gitignore: Whether to use .gitignore patterns
295
+ calculate_hashes: Whether to calculate file hashes
296
+ progress_callback: Callback for progress updates
297
+
298
+ Returns:
299
+ Root TreeNode of the built tree
300
+ """
301
+ self.stats = {
302
+ "directories_scanned": 0,
303
+ "files_found": 0,
304
+ "files_ignored": 0,
305
+ "total_size": 0,
306
+ "languages": set(),
307
+ }
308
+
309
+ # Load gitignore patterns if requested
310
+ gitignore_parser = GitignoreParser(root_path) if use_gitignore else None
311
+
312
+ # Combine ignore patterns
313
+ all_ignore_patterns = set(self.DEFAULT_IGNORE)
314
+ if ignore_patterns:
315
+ all_ignore_patterns.update(ignore_patterns)
316
+
317
+ # Build the tree
318
+ return self._build_node(
319
+ root_path,
320
+ root_path,
321
+ file_extensions,
322
+ all_ignore_patterns,
323
+ gitignore_parser,
324
+ max_depth,
325
+ 0,
326
+ calculate_hashes,
327
+ progress_callback,
328
+ )
329
+
330
+ def _build_node(
331
+ self,
332
+ path: Path,
333
+ root_path: Path,
334
+ file_extensions: Optional[List[str]],
335
+ ignore_patterns: Set[str],
336
+ gitignore_parser: Optional[GitignoreParser],
337
+ max_depth: Optional[int],
338
+ current_depth: int,
339
+ calculate_hashes: bool,
340
+ progress_callback: Optional[callable],
341
+ ) -> Optional[TreeNode]:
342
+ """Recursively build tree node.
343
+
344
+ Args:
345
+ path: Current path to process
346
+ root_path: Root directory of tree
347
+ file_extensions: File extensions to include
348
+ ignore_patterns: Patterns to ignore
349
+ gitignore_parser: Gitignore parser instance
350
+ max_depth: Maximum depth to traverse
351
+ current_depth: Current depth in tree
352
+ calculate_hashes: Whether to calculate file hashes
353
+ progress_callback: Progress callback function
354
+
355
+ Returns:
356
+ TreeNode or None if path should be ignored
357
+ """
358
+ # Check if we should ignore this path
359
+ if self._should_ignore(path, ignore_patterns):
360
+ self.stats["files_ignored"] += 1
361
+ return None
362
+
363
+ # Check gitignore
364
+ if gitignore_parser and gitignore_parser.should_ignore(path):
365
+ self.stats["files_ignored"] += 1
366
+ return None
367
+
368
+ # Check depth limit
369
+ if max_depth is not None and current_depth > max_depth:
370
+ return None
371
+
372
+ # Create node
373
+ node = TreeNode(
374
+ name=path.name,
375
+ path=str(path),
376
+ type="directory" if path.is_dir() else "file",
377
+ )
378
+
379
+ # Handle files
380
+ if path.is_file():
381
+ # Check file extension filter
382
+ if file_extensions:
383
+ if not any(path.suffix == ext for ext in file_extensions):
384
+ return None
385
+
386
+ # Get file metadata
387
+ stat = path.stat()
388
+ metadata = FileMetadata(
389
+ path=str(path),
390
+ size=stat.st_size,
391
+ modified=stat.st_mtime,
392
+ language=self._detect_language(path),
393
+ )
394
+
395
+ # Calculate hash if requested
396
+ if calculate_hashes:
397
+ metadata.hash = self._calculate_file_hash(path)
398
+
399
+ node.metadata = metadata
400
+
401
+ # Update statistics
402
+ self.stats["files_found"] += 1
403
+ self.stats["total_size"] += stat.st_size
404
+ if metadata.language:
405
+ self.stats["languages"].add(metadata.language)
406
+
407
+ # Progress callback
408
+ if progress_callback:
409
+ progress_callback(path, self.stats)
410
+
411
+ # Handle directories
412
+ elif path.is_dir():
413
+ self.stats["directories_scanned"] += 1
414
+
415
+ try:
416
+ # Process children
417
+ children = []
418
+ for child_path in sorted(path.iterdir()):
419
+ child_node = self._build_node(
420
+ child_path,
421
+ root_path,
422
+ file_extensions,
423
+ ignore_patterns,
424
+ gitignore_parser,
425
+ max_depth,
426
+ current_depth + 1,
427
+ calculate_hashes,
428
+ progress_callback,
429
+ )
430
+
431
+ if child_node:
432
+ children.append(child_node)
433
+
434
+ node.children = children
435
+
436
+ except PermissionError:
437
+ self.logger.warning(f"Permission denied: {path}")
438
+
439
+ return node
440
+
441
+ def _should_ignore(self, path: Path, ignore_patterns: Set[str]) -> bool:
442
+ """Check if path matches any ignore pattern.
443
+
444
+ Args:
445
+ path: Path to check
446
+ ignore_patterns: Set of ignore patterns
447
+
448
+ Returns:
449
+ True if path should be ignored
450
+ """
451
+ name = path.name
452
+
453
+ return any(fnmatch.fnmatch(name, pattern) for pattern in ignore_patterns)
454
+
455
+ def _detect_language(self, path: Path) -> Optional[str]:
456
+ """Detect programming language from file extension.
457
+
458
+ Args:
459
+ path: File path
460
+
461
+ Returns:
462
+ Language name or None
463
+ """
464
+ suffix = path.suffix.lower()
465
+
466
+ # Special case for Dockerfile
467
+ if path.name.lower() in ("dockerfile", "dockerfile.*"):
468
+ return "dockerfile"
469
+
470
+ return self.LANGUAGE_MAP.get(suffix)
471
+
472
+ def _calculate_file_hash(self, path: Path) -> str:
473
+ """Calculate MD5 hash of file contents.
474
+
475
+ Args:
476
+ path: File path
477
+
478
+ Returns:
479
+ MD5 hash string
480
+ """
481
+ hasher = hashlib.md5()
482
+
483
+ try:
484
+ with open(path, "rb") as f:
485
+ # Read in chunks for large files
486
+ while chunk := f.read(8192):
487
+ hasher.update(chunk)
488
+ except Exception as e:
489
+ self.logger.warning(f"Failed to hash {path}: {e}")
490
+ return ""
491
+
492
+ return hasher.hexdigest()
493
+
494
+ def save_tree(self, tree: TreeNode, output_path: Path):
495
+ """Save tree to JSON file.
496
+
497
+ Args:
498
+ tree: Root tree node
499
+ output_path: Output file path
500
+ """
501
+ tree_dict = tree.to_dict()
502
+ tree_dict["stats"] = {
503
+ "directories_scanned": self.stats["directories_scanned"],
504
+ "files_found": self.stats["files_found"],
505
+ "files_ignored": self.stats["files_ignored"],
506
+ "total_size": self.stats["total_size"],
507
+ "languages": list(self.stats["languages"]),
508
+ "generated_at": datetime.utcnow().isoformat(),
509
+ }
510
+
511
+ with open(output_path, "w") as f:
512
+ json.dump(tree_dict, f, indent=2)
513
+
514
+ self.logger.info(f"Saved tree to {output_path}")
515
+
516
+ def load_tree(self, input_path: Path) -> TreeNode:
517
+ """Load tree from JSON file.
518
+
519
+ Args:
520
+ input_path: Input file path
521
+
522
+ Returns:
523
+ Root tree node
524
+ """
525
+ with open(input_path) as f:
526
+ tree_dict = json.load(f)
527
+
528
+ # Remove stats if present
529
+ if "stats" in tree_dict:
530
+ del tree_dict["stats"]
531
+
532
+ return self._dict_to_node(tree_dict)
533
+
534
+ def _dict_to_node(self, node_dict: Dict[str, Any]) -> TreeNode:
535
+ """Convert dictionary to TreeNode.
536
+
537
+ Args:
538
+ node_dict: Node dictionary
539
+
540
+ Returns:
541
+ TreeNode instance
542
+ """
543
+ node = TreeNode(
544
+ name=node_dict["name"], path=node_dict["path"], type=node_dict["type"]
545
+ )
546
+
547
+ if "metadata" in node_dict:
548
+ meta = node_dict["metadata"]
549
+ node.metadata = FileMetadata(
550
+ path=node.path,
551
+ size=meta.get("size", 0),
552
+ modified=meta.get("modified", 0),
553
+ hash=meta.get("hash"),
554
+ language=meta.get("language"),
555
+ )
556
+
557
+ if "children" in node_dict:
558
+ node.children = [
559
+ self._dict_to_node(child_dict) for child_dict in node_dict["children"]
560
+ ]
561
+
562
+ return node
563
+
564
+ def get_stats(self) -> Dict[str, Any]:
565
+ """Get current statistics.
566
+
567
+ Returns:
568
+ Statistics dictionary
569
+ """
570
+ return {**self.stats, "languages": list(self.stats["languages"])}
571
+
572
+ def compare_trees(
573
+ self, old_tree: TreeNode, new_tree: TreeNode
574
+ ) -> Dict[str, List[str]]:
575
+ """Compare two trees to find differences.
576
+
577
+ Args:
578
+ old_tree: Previous tree
579
+ new_tree: Current tree
580
+
581
+ Returns:
582
+ Dictionary with added, removed, and modified files
583
+ """
584
+ old_files = self._get_all_files(old_tree)
585
+ new_files = self._get_all_files(new_tree)
586
+
587
+ old_paths = set(old_files.keys())
588
+ new_paths = set(new_files.keys())
589
+
590
+ added = list(new_paths - old_paths)
591
+ removed = list(old_paths - new_paths)
592
+
593
+ # Check for modifications
594
+ modified = []
595
+ for path in old_paths & new_paths:
596
+ old_meta = old_files[path]
597
+ new_meta = new_files[path]
598
+
599
+ # Compare modification times or hashes
600
+ if old_meta.hash and new_meta.hash:
601
+ if old_meta.hash != new_meta.hash:
602
+ modified.append(path)
603
+ elif old_meta.modified != new_meta.modified:
604
+ modified.append(path)
605
+
606
+ return {
607
+ "added": sorted(added),
608
+ "removed": sorted(removed),
609
+ "modified": sorted(modified),
610
+ }
611
+
612
+ def _get_all_files(self, tree: TreeNode) -> Dict[str, FileMetadata]:
613
+ """Get all files from tree.
614
+
615
+ Args:
616
+ tree: Root tree node
617
+
618
+ Returns:
619
+ Dictionary mapping file paths to metadata
620
+ """
621
+ files = {}
622
+
623
+ def traverse(node: TreeNode):
624
+ if node.type == "file" and node.metadata:
625
+ files[node.path] = node.metadata
626
+ elif node.children:
627
+ for child in node.children:
628
+ traverse(child)
629
+
630
+ traverse(tree)
631
+ return files