@voodocs/cli 0.1.1 → 0.2.0

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.
@@ -0,0 +1,1688 @@
1
+ """
2
+ Context System Commands
3
+
4
+ Implements the command-line interface for the context system.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from .models import create_minimal_context
12
+ from .yaml_utils import (
13
+ write_context_yaml,
14
+ read_context_yaml,
15
+ add_to_gitignore,
16
+ format_context_as_markdown
17
+ )
18
+
19
+
20
+ def get_project_root() -> Path:
21
+ """
22
+ Get the project root directory (current working directory).
23
+
24
+ Returns:
25
+ Path to the project root
26
+ """
27
+ return Path.cwd()
28
+
29
+
30
+ def get_context_file_path() -> Path:
31
+ """
32
+ Get the path to the .voodocs.context file.
33
+
34
+ Returns:
35
+ Path to .voodocs.context in the project root
36
+ """
37
+ return get_project_root() / '.voodocs.context'
38
+
39
+
40
+ def context_file_exists() -> bool:
41
+ """
42
+ Check if a .voodocs.context file already exists.
43
+
44
+ Returns:
45
+ True if file exists, False otherwise
46
+ """
47
+ return get_context_file_path().exists()
48
+
49
+
50
+ def prompt_user(question: str, default: Optional[str] = None, required: bool = True) -> str:
51
+ """
52
+ Prompt the user for input with an optional default value.
53
+
54
+ Args:
55
+ question: The question to ask
56
+ default: Optional default value
57
+ required: Whether the field is required
58
+
59
+ Returns:
60
+ User's response or default value
61
+ """
62
+ if default is not None:
63
+ response = input(f"{question} [{default}]: ").strip()
64
+ return response if response else default
65
+ else:
66
+ response = input(f"{question}: ").strip()
67
+ if required:
68
+ while not response:
69
+ print("This field is required.")
70
+ response = input(f"{question}: ").strip()
71
+ return response
72
+
73
+
74
+ def prompt_yes_no(question: str, default: bool = True) -> bool:
75
+ """
76
+ Prompt the user for a yes/no answer.
77
+
78
+ Args:
79
+ question: The question to ask
80
+ default: Default value (True for yes, False for no)
81
+
82
+ Returns:
83
+ True for yes, False for no
84
+ """
85
+ default_str = "Y/n" if default else "y/N"
86
+ response = input(f"{question} [{default_str}]: ").strip().lower()
87
+
88
+ if not response:
89
+ return default
90
+
91
+ return response in ['y', 'yes']
92
+
93
+
94
+ def detect_code_version() -> Optional[str]:
95
+ """
96
+ Try to detect the code version from common files.
97
+
98
+ Checks:
99
+ - package.json (version field)
100
+ - pyproject.toml (version field)
101
+ - setup.py (version argument)
102
+ - VERSION file
103
+
104
+ Returns:
105
+ Detected version string or None
106
+ """
107
+ project_root = get_project_root()
108
+
109
+ # Try package.json
110
+ package_json = project_root / 'package.json'
111
+ if package_json.exists():
112
+ try:
113
+ import json
114
+ with open(package_json, 'r') as f:
115
+ data = json.load(f)
116
+ if 'version' in data:
117
+ return data['version']
118
+ except:
119
+ pass
120
+
121
+ # Try pyproject.toml
122
+ pyproject_toml = project_root / 'pyproject.toml'
123
+ if pyproject_toml.exists():
124
+ try:
125
+ with open(pyproject_toml, 'r') as f:
126
+ for line in f:
127
+ if line.startswith('version'):
128
+ # Extract version from: version = "1.0.0"
129
+ version = line.split('=')[1].strip().strip('"\'')
130
+ return version
131
+ except:
132
+ pass
133
+
134
+ # Try VERSION file
135
+ version_file = project_root / 'VERSION'
136
+ if version_file.exists():
137
+ try:
138
+ with open(version_file, 'r') as f:
139
+ return f.read().strip()
140
+ except:
141
+ pass
142
+
143
+ return None
144
+
145
+
146
+ def detect_repository() -> Optional[str]:
147
+ """
148
+ Try to detect the repository URL from git config.
149
+
150
+ Returns:
151
+ Repository URL or None
152
+ """
153
+ try:
154
+ import subprocess
155
+ result = subprocess.run(
156
+ ['git', 'config', '--get', 'remote.origin.url'],
157
+ capture_output=True,
158
+ text=True,
159
+ cwd=get_project_root()
160
+ )
161
+ if result.returncode == 0:
162
+ url = result.stdout.strip()
163
+ # Convert SSH URL to HTTPS
164
+ if url.startswith('git@github.com:'):
165
+ url = url.replace('git@github.com:', 'https://github.com/')
166
+ if url.endswith('.git'):
167
+ url = url[:-4]
168
+ return url
169
+ except:
170
+ pass
171
+
172
+ return None
173
+
174
+
175
+ def cmd_context_init(force: bool = False) -> int:
176
+ """
177
+ Initialize a new .voodocs.context file for the project.
178
+
179
+ Args:
180
+ force: If True, overwrite existing context file
181
+
182
+ Returns:
183
+ Exit code (0 for success, 1 for error)
184
+ """
185
+ print("🎯 VooDocs Context Initialization")
186
+ print()
187
+
188
+ # Check if context file already exists
189
+ if context_file_exists() and not force:
190
+ print("❌ Error: .voodocs.context already exists in this directory.")
191
+ print(" Use --force to overwrite the existing file.")
192
+ return 1
193
+
194
+ # Detect project information
195
+ detected_version = detect_code_version()
196
+ detected_repo = detect_repository()
197
+
198
+ print("Let's set up your project context. This will create a .voodocs.context file.")
199
+ print()
200
+
201
+ # Prompt for project information
202
+ project_name = prompt_user("Project name")
203
+
204
+ project_purpose = prompt_user("Project purpose (one sentence)")
205
+
206
+ if detected_version:
207
+ code_version = prompt_user("Current code version", detected_version)
208
+ else:
209
+ code_version = prompt_user("Current code version", "1.0.0")
210
+
211
+ # Validate version format
212
+ if not code_version.count('.') >= 2:
213
+ print(f"⚠️ Warning: '{code_version}' doesn't look like semantic versioning (MAJOR.MINOR.PATCH)")
214
+ if not prompt_yes_no("Continue anyway?", default=False):
215
+ print("Aborted.")
216
+ return 1
217
+
218
+ if detected_repo:
219
+ repository = prompt_user("Repository URL (leave empty to skip)", detected_repo, required=False)
220
+ else:
221
+ repository = prompt_user("Repository URL (leave empty to skip)", "", required=False)
222
+
223
+ license_name = prompt_user("License (leave empty to skip)", "", required=False)
224
+
225
+ print()
226
+ print("📝 Configuration Summary:")
227
+ print(f" Project Name: {project_name}")
228
+ print(f" Purpose: {project_purpose}")
229
+ print(f" Code Version: {code_version}")
230
+ if repository:
231
+ print(f" Repository: {repository}")
232
+ if license_name:
233
+ print(f" License: {license_name}")
234
+ print()
235
+
236
+ if not prompt_yes_no("Create context file?", default=True):
237
+ print("Aborted.")
238
+ return 1
239
+
240
+ # Create context file
241
+ try:
242
+ context = create_minimal_context(
243
+ project_name=project_name,
244
+ project_purpose=project_purpose,
245
+ code_version=code_version,
246
+ repository=repository if repository else None,
247
+ license=license_name if license_name else None
248
+ )
249
+
250
+ context_path = get_context_file_path()
251
+ write_context_yaml(context, context_path)
252
+
253
+ print()
254
+ print(f"✅ Created .voodocs.context")
255
+ print(f" Context version: {context.versioning.context_version}")
256
+ print()
257
+
258
+ # Ask about .gitignore
259
+ if prompt_yes_no("Add .voodocs.context to .gitignore? (recommended for private projects)", default=True):
260
+ if add_to_gitignore(get_project_root()):
261
+ print("✅ Added to .gitignore")
262
+ else:
263
+ print("ℹ️ Already in .gitignore")
264
+
265
+ print()
266
+ print("🎉 Context initialized successfully!")
267
+ print()
268
+ print("Next steps:")
269
+ print(" 1. Review the context file: voodocs context view")
270
+ print(" 2. Add global invariants to the 'invariants.global' section")
271
+ print(" 3. Document architecture decisions in the 'architecture.decisions' section")
272
+ print(" 4. Update context when making changes: voodocs context update")
273
+ print()
274
+
275
+ return 0
276
+
277
+ except Exception as e:
278
+ print(f"❌ Error creating context file: {e}")
279
+ return 1
280
+
281
+
282
+ def cmd_context_view(output_file: Optional[str] = None) -> int:
283
+ """
284
+ View the context file as human-readable Markdown.
285
+
286
+ Args:
287
+ output_file: Optional path to write Markdown output
288
+
289
+ Returns:
290
+ Exit code (0 for success, 1 for error)
291
+ """
292
+ if not context_file_exists():
293
+ print("❌ Error: No .voodocs.context file found in this directory.")
294
+ print(" Run 'voodocs context init' to create one.")
295
+ return 1
296
+
297
+ try:
298
+ # Read context file
299
+ context_path = get_context_file_path()
300
+ data = read_context_yaml(context_path)
301
+
302
+ if not data:
303
+ print("❌ Error: Failed to read .voodocs.context file.")
304
+ return 1
305
+
306
+ # Parse and format as Markdown
307
+ from .yaml_utils import parse_context_file
308
+ context = parse_context_file(data)
309
+ markdown = format_context_as_markdown(context)
310
+
311
+ if output_file:
312
+ # Write to file
313
+ output_path = Path(output_file)
314
+ with open(output_path, 'w', encoding='utf-8') as f:
315
+ f.write(markdown)
316
+ print(f"✅ Context exported to {output_file}")
317
+ return 0
318
+ else:
319
+ # Print to console
320
+ print(markdown)
321
+ return 0
322
+
323
+ except Exception as e:
324
+ print(f"❌ Error viewing context: {e}")
325
+ return 1
326
+
327
+
328
+ def cmd_context_status() -> int:
329
+ """
330
+ Show the current status of the context file.
331
+
332
+ Returns:
333
+ Exit code (0 for success, 1 for error)
334
+ """
335
+ if not context_file_exists():
336
+ print("❌ No context file found")
337
+ print(" Run 'voodocs context init' to create one.")
338
+ return 1
339
+
340
+ try:
341
+ # Read context file
342
+ context_path = get_context_file_path()
343
+ data = read_context_yaml(context_path)
344
+
345
+ if not data:
346
+ print("❌ Error: Failed to read .voodocs.context file.")
347
+ return 1
348
+
349
+ # Extract key information
350
+ versioning = data.get('versioning', {})
351
+ project = data.get('project', {})
352
+ invariants = data.get('invariants', {})
353
+
354
+ print("📊 Context Status")
355
+ print()
356
+ print(f"Project: {project.get('name', 'Unknown')}")
357
+ print(f"Code Version: {versioning.get('code_version', 'Unknown')}")
358
+ print(f"Context Version: {versioning.get('context_version', 'Unknown')}")
359
+ print(f"Last Updated: {versioning.get('last_updated', 'Unknown')}")
360
+ print()
361
+
362
+ # Count sections
363
+ global_invariants = invariants.get('global', [])
364
+ print(f"Global Invariants: {len(global_invariants)}")
365
+
366
+ arch_decisions = data.get('architecture', {}).get('decisions', [])
367
+ print(f"Architecture Decisions: {len(arch_decisions)}")
368
+
369
+ critical_paths = data.get('critical_paths', [])
370
+ print(f"Critical Paths: {len(critical_paths)}")
371
+
372
+ known_issues = data.get('known_issues', [])
373
+ print(f"Known Issues: {len(known_issues)}")
374
+
375
+ print()
376
+ print("Use 'voodocs context view' to see the full context.")
377
+
378
+ return 0
379
+
380
+ except Exception as e:
381
+ print(f"❌ Error reading context: {e}")
382
+ return 1
383
+
384
+
385
+ def cmd_context_update(description: Optional[str] = None) -> int:
386
+ """
387
+ Update the context file and increment the minor version.
388
+
389
+ Args:
390
+ description: Optional description of the changes
391
+
392
+ Returns:
393
+ Exit code (0 for success, 1 for error)
394
+ """
395
+ if not context_file_exists():
396
+ print("❌ Error: No .voodocs.context file found in this directory.")
397
+ print(" Run 'voodocs context init' to create one.")
398
+ return 1
399
+
400
+ try:
401
+ from datetime import date
402
+ from .models import Change
403
+
404
+ # Read current context
405
+ context_path = get_context_file_path()
406
+ data = read_context_yaml(context_path)
407
+
408
+ if not data:
409
+ print("❌ Error: Failed to read .voodocs.context file.")
410
+ return 1
411
+
412
+ # Get current versions
413
+ versioning = data.get('versioning', {})
414
+ current_context_version = versioning.get('context_version', '0.0')
415
+ code_version = versioning.get('code_version', '0.0.0')
416
+
417
+ # Parse context version
418
+ parts = current_context_version.split('.')
419
+ if len(parts) != 2:
420
+ print(f"❌ Error: Invalid context version format: {current_context_version}")
421
+ return 1
422
+
423
+ major, minor = parts
424
+ new_minor = int(minor) + 1
425
+ new_context_version = f"{major}.{new_minor}"
426
+
427
+ print(f"📝 Updating Context")
428
+ print()
429
+ print(f"Current version: {current_context_version}")
430
+ print(f"New version: {new_context_version}")
431
+ print()
432
+
433
+ # Prompt for description if not provided
434
+ if not description:
435
+ description = prompt_user("Change description", "", required=False)
436
+ if not description:
437
+ description = "Context updated"
438
+
439
+ # Update versioning
440
+ today = date.today().isoformat()
441
+ data['versioning']['context_version'] = new_context_version
442
+ data['versioning']['last_updated'] = today
443
+
444
+ # Add change entry
445
+ if 'changes' not in data:
446
+ data['changes'] = []
447
+
448
+ # Get Git information if available
449
+ commit_hash = get_git_commit_hash()
450
+ author = get_git_author()
451
+
452
+ change_entry = {
453
+ 'type': 'context',
454
+ 'description': description,
455
+ 'date': today,
456
+ 'context_version': new_context_version,
457
+ 'code_version': code_version
458
+ }
459
+
460
+ if commit_hash:
461
+ change_entry['commit'] = commit_hash
462
+ if author:
463
+ change_entry['author'] = author
464
+
465
+ data['changes'].append(change_entry)
466
+
467
+ # Write updated context
468
+ from .yaml_utils import parse_context_file
469
+ context = parse_context_file(data)
470
+ write_context_yaml(context, context_path)
471
+
472
+ print(f"✅ Context updated to version {new_context_version}")
473
+ print(f" Description: {description}")
474
+ print()
475
+
476
+ return 0
477
+
478
+ except Exception as e:
479
+ print(f"❌ Error updating context: {e}")
480
+ import traceback
481
+ traceback.print_exc()
482
+ return 1
483
+
484
+
485
+ def cmd_context_sync(code_version: Optional[str] = None, force: bool = False) -> int:
486
+ """
487
+ Sync context version with code version (handles major version bumps).
488
+
489
+ Args:
490
+ code_version: New code version (e.g., "2.0.0")
491
+ force: Skip confirmation prompt
492
+
493
+ Returns:
494
+ Exit code (0 for success, 1 for error)
495
+ """
496
+ if not context_file_exists():
497
+ print("❌ Error: No .voodocs.context file found in this directory.")
498
+ print(" Run 'voodocs context init' to create one.")
499
+ return 1
500
+
501
+ try:
502
+ from datetime import date
503
+
504
+ # Read current context
505
+ context_path = get_context_file_path()
506
+ data = read_context_yaml(context_path)
507
+
508
+ if not data:
509
+ print("❌ Error: Failed to read .voodocs.context file.")
510
+ return 1
511
+
512
+ # Get current versions
513
+ versioning = data.get('versioning', {})
514
+ current_code_version = versioning.get('code_version', '0.0.0')
515
+ current_context_version = versioning.get('context_version', '0.0')
516
+
517
+ # If no code version provided, try to detect it
518
+ if not code_version:
519
+ detected_version = detect_code_version()
520
+ if detected_version:
521
+ code_version = prompt_user(
522
+ "New code version",
523
+ detected_version,
524
+ required=True
525
+ )
526
+ else:
527
+ code_version = prompt_user(
528
+ "New code version",
529
+ required=True
530
+ )
531
+
532
+ # Validate version format
533
+ if code_version.count('.') < 2:
534
+ print(f"❌ Error: Invalid version format: {code_version}")
535
+ print(" Expected: MAJOR.MINOR.PATCH (e.g., 2.0.0)")
536
+ return 1
537
+
538
+ # Extract major versions
539
+ current_major = current_code_version.split('.')[0]
540
+ new_major = code_version.split('.')[0]
541
+ context_major = current_context_version.split('.')[0]
542
+
543
+ print(f"🔄 Syncing Context with Code Version")
544
+ print()
545
+ print(f"Current code version: {current_code_version}")
546
+ print(f"New code version: {code_version}")
547
+ print(f"Current context version: {current_context_version}")
548
+ print()
549
+
550
+ # Check if major version changed
551
+ if current_major != new_major:
552
+ # Major version bump - reset context version
553
+ new_context_version = f"{new_major}.0"
554
+
555
+ print(f"⚠️ MAJOR version change detected ({current_major}.x.x → {new_major}.0.0)")
556
+ print(f" Context version will be reset: {current_context_version} → {new_context_version}")
557
+ print()
558
+
559
+ if not force:
560
+ if not prompt_yes_no("Continue with reset?", default=True):
561
+ print("Aborted.")
562
+ return 1
563
+
564
+ # Update versions
565
+ today = date.today().isoformat()
566
+ data['versioning']['code_version'] = code_version
567
+ data['versioning']['context_version'] = new_context_version
568
+ data['versioning']['last_updated'] = today
569
+
570
+ # Add change entry
571
+ if 'changes' not in data:
572
+ data['changes'] = []
573
+
574
+ # Get Git information if available
575
+ commit_hash = get_git_commit_hash()
576
+ author = get_git_author()
577
+
578
+ change_entry = {
579
+ 'type': 'context',
580
+ 'description': f"Reset for v{code_version} release",
581
+ 'date': today,
582
+ 'context_version': new_context_version,
583
+ 'code_version': code_version
584
+ }
585
+
586
+ if commit_hash:
587
+ change_entry['commit'] = commit_hash
588
+ if author:
589
+ change_entry['author'] = author
590
+
591
+ data['changes'].append(change_entry)
592
+
593
+ # Write updated context
594
+ from .yaml_utils import parse_context_file
595
+ context = parse_context_file(data)
596
+ write_context_yaml(context, context_path)
597
+
598
+ print(f"✅ Context version reset to {new_context_version}")
599
+ print(f" Code version updated to {code_version}")
600
+ print()
601
+
602
+ elif context_major != new_major:
603
+ # Context major doesn't match new code major - this shouldn't happen
604
+ print(f"⚠️ Warning: Context MAJOR ({context_major}) doesn't match code MAJOR ({new_major})")
605
+ print(f" Updating context to match...")
606
+
607
+ new_context_version = f"{new_major}.0"
608
+
609
+ # Update versions
610
+ today = date.today().isoformat()
611
+ data['versioning']['code_version'] = code_version
612
+ data['versioning']['context_version'] = new_context_version
613
+ data['versioning']['last_updated'] = today
614
+
615
+ # Add change entry
616
+ if 'changes' not in data:
617
+ data['changes'] = []
618
+
619
+ # Get Git information if available
620
+ commit_hash = get_git_commit_hash()
621
+ author = get_git_author()
622
+
623
+ change_entry = {
624
+ 'type': 'context',
625
+ 'description': f"Synced with v{code_version}",
626
+ 'date': today,
627
+ 'context_version': new_context_version,
628
+ 'code_version': code_version
629
+ }
630
+
631
+ if commit_hash:
632
+ change_entry['commit'] = commit_hash
633
+ if author:
634
+ change_entry['author'] = author
635
+
636
+ data['changes'].append(change_entry)
637
+
638
+ # Write updated context
639
+ from .yaml_utils import parse_context_file
640
+ context = parse_context_file(data)
641
+ write_context_yaml(context, context_path)
642
+
643
+ print(f"✅ Context synced to {new_context_version}")
644
+ print(f" Code version updated to {code_version}")
645
+ print()
646
+
647
+ else:
648
+ # No major version change - just update code version
649
+ data['versioning']['code_version'] = code_version
650
+ data['versioning']['last_updated'] = date.today().isoformat()
651
+
652
+ # Write updated context
653
+ from .yaml_utils import parse_context_file
654
+ context = parse_context_file(data)
655
+ write_context_yaml(context, context_path)
656
+
657
+ print(f"✅ Code version updated to {code_version}")
658
+ print(f" Context version unchanged: {current_context_version}")
659
+ print()
660
+
661
+ return 0
662
+
663
+ except Exception as e:
664
+ print(f"❌ Error syncing context: {e}")
665
+ import traceback
666
+ traceback.print_exc()
667
+ return 1
668
+
669
+
670
+ def cmd_context_history() -> int:
671
+ """
672
+ Show the version history of the context file.
673
+
674
+ Returns:
675
+ Exit code (0 for success, 1 for error)
676
+ """
677
+ if not context_file_exists():
678
+ print("❌ Error: No .voodocs.context file found in this directory.")
679
+ print(" Run 'voodocs context init' to create one.")
680
+ return 1
681
+
682
+ try:
683
+ # Read current context
684
+ context_path = get_context_file_path()
685
+ data = read_context_yaml(context_path)
686
+
687
+ if not data:
688
+ print("❌ Error: Failed to read .voodocs.context file.")
689
+ return 1
690
+
691
+ # Get changes
692
+ changes = data.get('changes', [])
693
+
694
+ if not changes:
695
+ print("📜 No change history found.")
696
+ print(" Changes will be recorded when you run 'voodocs context update'.")
697
+ return 0
698
+
699
+ print("📜 Context Version History")
700
+ print()
701
+
702
+ # Display changes in reverse chronological order
703
+ for change in reversed(changes):
704
+ change_type = change.get('type', 'unknown')
705
+ description = change.get('description', 'No description')
706
+ date = change.get('date', 'Unknown date')
707
+ context_version = change.get('context_version', '?')
708
+ code_version = change.get('code_version', '?')
709
+ commit = change.get('commit', '')
710
+ author = change.get('author', '')
711
+
712
+ # Format output
713
+ print(f"📌 Context v{context_version} (Code v{code_version})")
714
+ print(f" Date: {date}")
715
+ print(f" Type: {change_type}")
716
+ print(f" Description: {description}")
717
+
718
+ if commit:
719
+ print(f" Commit: {commit}")
720
+ if author:
721
+ print(f" Author: {author}")
722
+
723
+ print()
724
+
725
+ return 0
726
+
727
+ except Exception as e:
728
+ print(f"❌ Error reading history: {e}")
729
+ return 1
730
+
731
+
732
+ def cmd_context_diff(version1: Optional[str] = None, version2: Optional[str] = None) -> int:
733
+ """
734
+ Compare two versions of the context (currently shows change log between versions).
735
+
736
+ Args:
737
+ version1: First version to compare (older)
738
+ version2: Second version to compare (newer, default: current)
739
+
740
+ Returns:
741
+ Exit code (0 for success, 1 for error)
742
+ """
743
+ if not context_file_exists():
744
+ print("❌ Error: No .voodocs.context file found in this directory.")
745
+ print(" Run 'voodocs context init' to create one.")
746
+ return 1
747
+
748
+ try:
749
+ # Read current context
750
+ context_path = get_context_file_path()
751
+ data = read_context_yaml(context_path)
752
+
753
+ if not data:
754
+ print("❌ Error: Failed to read .voodocs.context file.")
755
+ return 1
756
+
757
+ # Get current version
758
+ versioning = data.get('versioning', {})
759
+ current_version = versioning.get('context_version', '0.0')
760
+
761
+ # Default version2 to current
762
+ if not version2:
763
+ version2 = current_version
764
+
765
+ # If no version1 provided, show changes from previous version
766
+ if not version1:
767
+ changes = data.get('changes', [])
768
+ if len(changes) < 2:
769
+ print("📊 Not enough version history to compare.")
770
+ print(" Need at least 2 versions. Run 'voodocs context history' to see available versions.")
771
+ return 0
772
+
773
+ # Get the two most recent versions
774
+ recent_changes = list(reversed(changes))[:2]
775
+ version2 = recent_changes[0].get('context_version', '?')
776
+ version1 = recent_changes[1].get('context_version', '?')
777
+
778
+ print(f"📊 Context Diff: v{version1} → v{version2}")
779
+ print()
780
+
781
+ # Get changes between versions
782
+ changes = data.get('changes', [])
783
+ relevant_changes = []
784
+
785
+ for change in changes:
786
+ change_version = change.get('context_version', '')
787
+
788
+ # Parse versions for comparison
789
+ try:
790
+ v1_parts = [int(x) for x in version1.split('.')]
791
+ v2_parts = [int(x) for x in version2.split('.')]
792
+ cv_parts = [int(x) for x in change_version.split('.')]
793
+
794
+ # Check if change is between v1 and v2
795
+ if v1_parts < cv_parts <= v2_parts:
796
+ relevant_changes.append(change)
797
+ except (ValueError, AttributeError):
798
+ # Skip if version parsing fails
799
+ continue
800
+
801
+ if not relevant_changes:
802
+ print(f"No changes found between v{version1} and v{version2}")
803
+ return 0
804
+
805
+ print(f"Changes ({len(relevant_changes)}):")
806
+ print()
807
+
808
+ for change in relevant_changes:
809
+ change_type = change.get('type', 'unknown')
810
+ description = change.get('description', 'No description')
811
+ date = change.get('date', 'Unknown date')
812
+ context_version = change.get('context_version', '?')
813
+ code_version = change.get('code_version', '?')
814
+
815
+ print(f" • v{context_version} ({date})")
816
+ print(f" {description}")
817
+ if code_version:
818
+ print(f" Code: v{code_version}")
819
+ print()
820
+
821
+ print(f"💡 Tip: Use 'voodocs context history' to see the full timeline.")
822
+
823
+ return 0
824
+
825
+ except Exception as e:
826
+ print(f"❌ Error comparing versions: {e}")
827
+ import traceback
828
+ traceback.print_exc()
829
+ return 1
830
+
831
+
832
+ def cmd_context_validate(check_version: bool = False, check_invariants: bool = False) -> int:
833
+ """
834
+ Validate the context file for correctness and consistency.
835
+
836
+ Args:
837
+ check_version: Check if code version matches detected version
838
+ check_invariants: Check if code respects documented invariants (future)
839
+
840
+ Returns:
841
+ Exit code (0 for success, 1 for warnings, 2 for errors)
842
+ """
843
+ if not context_file_exists():
844
+ print("❌ Error: No .voodocs.context file found in this directory.")
845
+ print(" Run 'voodocs context init' to create one.")
846
+ return 2
847
+
848
+ try:
849
+ # Read current context
850
+ context_path = get_context_file_path()
851
+ data = read_context_yaml(context_path)
852
+
853
+ if not data:
854
+ print("❌ Error: Failed to read .voodocs.context file.")
855
+ return 2
856
+
857
+ print("🔍 Validating Context File")
858
+ print()
859
+
860
+ errors = []
861
+ warnings = []
862
+
863
+ # Check required fields
864
+ versioning = data.get('versioning', {})
865
+ if not versioning:
866
+ errors.append("Missing 'versioning' section")
867
+ else:
868
+ if not versioning.get('code_version'):
869
+ errors.append("Missing 'versioning.code_version'")
870
+ if not versioning.get('context_version'):
871
+ errors.append("Missing 'versioning.context_version'")
872
+ if not versioning.get('last_updated'):
873
+ warnings.append("Missing 'versioning.last_updated'")
874
+
875
+ project = data.get('project', {})
876
+ if not project:
877
+ errors.append("Missing 'project' section")
878
+ else:
879
+ if not project.get('name'):
880
+ errors.append("Missing 'project.name'")
881
+ if not project.get('purpose'):
882
+ warnings.append("Missing 'project.purpose'")
883
+
884
+ # Check version format
885
+ code_version = versioning.get('code_version', '')
886
+ if code_version:
887
+ if code_version.count('.') < 2:
888
+ errors.append(f"Invalid code version format: '{code_version}' (expected MAJOR.MINOR.PATCH)")
889
+
890
+ context_version = versioning.get('context_version', '')
891
+ if context_version:
892
+ if context_version.count('.') != 1:
893
+ errors.append(f"Invalid context version format: '{context_version}' (expected MAJOR.MINOR)")
894
+
895
+ # Check version consistency
896
+ if code_version and context_version:
897
+ code_major = code_version.split('.')[0]
898
+ context_major = context_version.split('.')[0]
899
+
900
+ if code_major != context_major:
901
+ warnings.append(
902
+ f"Version mismatch: code MAJOR ({code_major}) != context MAJOR ({context_major})"
903
+ )
904
+
905
+ # Check if code version matches detected version
906
+ if check_version:
907
+ detected_version = detect_code_version()
908
+ if detected_version and detected_version != code_version:
909
+ warnings.append(
910
+ f"Code version mismatch: context has '{code_version}' but detected '{detected_version}'"
911
+ )
912
+
913
+ # Check invariants using the checker
914
+ if check_invariants:
915
+ invariants = data.get('invariants', {}).get('global', [])
916
+ if invariants:
917
+ from .checker import InvariantChecker
918
+ checker = InvariantChecker()
919
+ results = checker.check_invariants(invariants, get_project_root(), None)
920
+
921
+ failed = sum(1 for r in results if not r.passed)
922
+ if failed > 0:
923
+ warnings.append(f"{failed} invariant(s) have potential violations")
924
+ warnings.append("Run 'voodocs context check' for details")
925
+
926
+ # Display results
927
+ if errors:
928
+ print("❌ Errors:")
929
+ for error in errors:
930
+ print(f" • {error}")
931
+ print()
932
+
933
+ if warnings:
934
+ print("⚠️ Warnings:")
935
+ for warning in warnings:
936
+ print(f" • {warning}")
937
+ print()
938
+
939
+ # Add smart suggestions
940
+ suggestions = []
941
+
942
+ # Completeness suggestions
943
+ if not data.get('architecture', {}).get('modules'):
944
+ suggestions.append("Consider adding 'architecture.modules' section")
945
+
946
+ if not data.get('invariants', {}).get('global'):
947
+ suggestions.append("Consider adding 'invariants.global' section")
948
+
949
+ if not data.get('critical_paths'):
950
+ suggestions.append("Consider adding 'critical_paths' section")
951
+
952
+ if not data.get('dependencies'):
953
+ suggestions.append("Consider adding 'dependencies' section")
954
+
955
+ # Quality suggestions
956
+ invariants = data.get('invariants', {}).get('global', [])
957
+ for inv in invariants:
958
+ if len(inv) < 20:
959
+ suggestions.append(f"Invariant '{inv}' is too vague - be more specific")
960
+ break
961
+
962
+ # Consistency suggestions
963
+ modules = data.get('architecture', {}).get('modules', {})
964
+ critical_paths = data.get('critical_paths', [])
965
+
966
+ # Check if critical paths reference modules
967
+ for path in critical_paths:
968
+ path_name = path.get('name', '')
969
+ steps = path.get('steps', [])
970
+ for step in steps:
971
+ step_text = str(step).lower()
972
+ for module_name in modules.keys():
973
+ if module_name.lower() in step_text:
974
+ break
975
+
976
+ if not errors and not warnings:
977
+ print("✅ Context file is valid!")
978
+ print()
979
+
980
+ # Show summary
981
+ print("Summary:")
982
+ print(f" Project: {project.get('name', '?')}")
983
+ print(f" Code Version: {code_version}")
984
+ print(f" Context Version: {context_version}")
985
+ print(f" Last Updated: {versioning.get('last_updated', '?')}")
986
+ print()
987
+
988
+ # Show suggestions if any
989
+ if suggestions:
990
+ print("💡 Suggestions:")
991
+ for suggestion in suggestions[:5]: # Limit to 5
992
+ print(f" • {suggestion}")
993
+ print()
994
+
995
+ return 0
996
+ elif errors:
997
+ print(f"❌ Validation failed with {len(errors)} error(s) and {len(warnings)} warning(s)")
998
+ return 2
999
+ else:
1000
+ print(f"⚠️ Validation passed with {len(warnings)} warning(s)")
1001
+ return 1
1002
+
1003
+ except Exception as e:
1004
+ print(f"❌ Error validating context: {e}")
1005
+ import traceback
1006
+ traceback.print_exc()
1007
+ return 2
1008
+
1009
+
1010
+ def cmd_context_generate(source_dir: Optional[str] = None, update_existing: bool = False) -> int:
1011
+ """
1012
+ Generate or update context by extracting information from code annotations.
1013
+
1014
+ Args:
1015
+ source_dir: Directory to scan for annotations (default: current directory)
1016
+ update_existing: Update existing context file instead of creating new one
1017
+
1018
+ Returns:
1019
+ Exit code (0 for success, 1 for error)
1020
+ """
1021
+ from pathlib import Path
1022
+ import sys
1023
+
1024
+ # Determine source directory
1025
+ if source_dir:
1026
+ scan_path = Path(source_dir).resolve()
1027
+ else:
1028
+ scan_path = Path.cwd()
1029
+
1030
+ if not scan_path.exists():
1031
+ print(f"❌ Error: Directory not found: {scan_path}")
1032
+ return 1
1033
+
1034
+ print(f"🔍 Scanning for @voodocs annotations in: {scan_path}")
1035
+ print()
1036
+
1037
+ try:
1038
+ # Import the annotation parser
1039
+ sys.path.insert(0, str(Path(__file__).parent.parent))
1040
+ from annotations.parser import AnnotationParser
1041
+
1042
+ # Scan for annotations
1043
+ parser = AnnotationParser()
1044
+ results = parser.parse_directory(scan_path)
1045
+
1046
+ if not results:
1047
+ print("⚠️ No @voodocs annotations found.")
1048
+ print(" Add @voodocs annotations to your code first.")
1049
+ return 1
1050
+
1051
+ # Extract global invariants from all annotations
1052
+ global_invariants = set()
1053
+ assumptions = []
1054
+ modules_info = {}
1055
+
1056
+ for parsed in results:
1057
+ # Get module annotation
1058
+ module_ann = parsed.module
1059
+
1060
+ # Extract module-level invariants
1061
+ if hasattr(module_ann, 'invariants') and module_ann.invariants:
1062
+ for inv in module_ann.invariants:
1063
+ global_invariants.add(inv)
1064
+
1065
+ # Extract assumptions
1066
+ if hasattr(module_ann, 'assumptions') and module_ann.assumptions:
1067
+ for assumption in module_ann.assumptions:
1068
+ assumptions.append({
1069
+ 'description': assumption,
1070
+ 'source': Path(parsed.source_file).relative_to(scan_path).as_posix()
1071
+ })
1072
+
1073
+ # Store module info
1074
+ if hasattr(module_ann, 'module_purpose') and module_ann.module_purpose:
1075
+ rel_path = Path(parsed.source_file).relative_to(scan_path).as_posix()
1076
+ modules_info[rel_path] = {
1077
+ 'purpose': module_ann.module_purpose,
1078
+ 'dependencies': getattr(module_ann, 'dependencies', [])
1079
+ }
1080
+
1081
+ # Extract function-level invariants that might be global
1082
+ for func_ann in module_ann.functions:
1083
+ if hasattr(func_ann, 'invariants') and func_ann.invariants:
1084
+ for inv in func_ann.invariants:
1085
+ # Check if invariant looks global (mentions system-wide concepts)
1086
+ if any(keyword in inv.lower() for keyword in ['system', 'all', 'every', 'global', '∀']):
1087
+ global_invariants.add(inv)
1088
+
1089
+ # Extract class-level invariants
1090
+ for cls_ann in module_ann.classes:
1091
+ if hasattr(cls_ann, 'class_invariants') and cls_ann.class_invariants:
1092
+ for inv in cls_ann.class_invariants:
1093
+ # Check if invariant looks global
1094
+ if any(keyword in inv.lower() for keyword in ['system', 'all', 'every', 'global', '∀']):
1095
+ global_invariants.add(inv)
1096
+
1097
+ # Display findings
1098
+ print(f"📊 Extraction Results:")
1099
+ print(f" Files scanned: {len(results)}")
1100
+ print(f" Global invariants: {len(global_invariants)}")
1101
+ print(f" Assumptions: {len(assumptions)}")
1102
+ print(f" Modules documented: {len(modules_info)}")
1103
+ print()
1104
+
1105
+ if not update_existing:
1106
+ # Check if context already exists
1107
+ if context_file_exists():
1108
+ print("⚠️ Context file already exists.")
1109
+ response = prompt_user("Update existing context?", "y", required=False)
1110
+ if response.lower() not in ['y', 'yes']:
1111
+ print("Cancelled.")
1112
+ return 0
1113
+ update_existing = True
1114
+
1115
+ if update_existing:
1116
+ # Update existing context
1117
+ if not context_file_exists():
1118
+ print("❌ Error: No .voodocs.context file found.")
1119
+ print(" Run 'voodocs context init' first.")
1120
+ return 1
1121
+
1122
+ context_path = get_context_file_path()
1123
+ data = read_context_yaml(context_path)
1124
+
1125
+ if not data:
1126
+ print("❌ Error: Failed to read .voodocs.context file.")
1127
+ return 1
1128
+
1129
+ # Update invariants
1130
+ if 'invariants' not in data:
1131
+ data['invariants'] = {}
1132
+ if 'global' not in data['invariants']:
1133
+ data['invariants']['global'] = []
1134
+
1135
+ # Merge new invariants (avoid duplicates)
1136
+ existing_invs = set(data['invariants']['global'])
1137
+ new_invs = global_invariants - existing_invs
1138
+ data['invariants']['global'].extend(sorted(new_invs))
1139
+
1140
+ # Update assumptions
1141
+ if 'assumptions' not in data:
1142
+ data['assumptions'] = []
1143
+
1144
+ # Merge new assumptions
1145
+ existing_assumption_texts = {a.get('description', '') for a in data['assumptions']}
1146
+ for assumption in assumptions:
1147
+ if assumption['description'] not in existing_assumption_texts:
1148
+ data['assumptions'].append(assumption)
1149
+
1150
+ # Update modules
1151
+ if 'architecture' not in data:
1152
+ data['architecture'] = {}
1153
+ if 'modules' not in data['architecture']:
1154
+ data['architecture']['modules'] = {}
1155
+
1156
+ data['architecture']['modules'].update(modules_info)
1157
+
1158
+ # Update last_updated
1159
+ from datetime import date
1160
+ data['versioning']['last_updated'] = date.today().isoformat()
1161
+
1162
+ # Write updated context
1163
+ from .yaml_utils import parse_context_file
1164
+ context = parse_context_file(data)
1165
+ write_context_yaml(context, context_path)
1166
+
1167
+ print(f"✅ Context updated from code annotations")
1168
+ print(f" Added {len(new_invs)} new invariants")
1169
+ print(f" Added {len(assumptions) - len(existing_assumption_texts)} new assumptions")
1170
+ print()
1171
+ print("💡 Tip: Run 'voodocs context view' to see the updated context")
1172
+
1173
+ else:
1174
+ # Create new context (should not reach here, but handle gracefully)
1175
+ print("❌ Error: Context generation requires an existing context file.")
1176
+ print(" Run 'voodocs context init' first, then use 'voodocs context generate --from-code'")
1177
+ return 1
1178
+
1179
+ return 0
1180
+
1181
+ except ImportError as e:
1182
+ print(f"❌ Error: Failed to import annotation parser: {e}")
1183
+ print(" Make sure VooDocs is properly installed.")
1184
+ return 1
1185
+ except Exception as e:
1186
+ print(f"❌ Error generating context: {e}")
1187
+ import traceback
1188
+ traceback.print_exc()
1189
+ return 1
1190
+
1191
+
1192
+ def cmd_context_query(query: str, section: Optional[str] = None, format: str = 'text') -> int:
1193
+ """
1194
+ Query the context file like a database.
1195
+
1196
+ Args:
1197
+ query: Search query (keyword or regex pattern)
1198
+ section: Specific section to search (invariants, assumptions, etc.)
1199
+ format: Output format ('text', 'json', 'yaml')
1200
+
1201
+ Returns:
1202
+ Exit code (0 for success, 1 for error)
1203
+ """
1204
+ if not context_file_exists():
1205
+ print("❌ Error: No .voodocs.context file found in this directory.")
1206
+ print(" Run 'voodocs context init' to create one.")
1207
+ return 1
1208
+
1209
+ try:
1210
+ import re
1211
+ import json
1212
+
1213
+ # Read context
1214
+ context_path = get_context_file_path()
1215
+ data = read_context_yaml(context_path)
1216
+
1217
+ if not data:
1218
+ print("❌ Error: Failed to read .voodocs.context file.")
1219
+ return 1
1220
+
1221
+ # Compile regex pattern (case-insensitive)
1222
+ try:
1223
+ pattern = re.compile(query, re.IGNORECASE)
1224
+ except re.error as e:
1225
+ print(f"❌ Error: Invalid regex pattern: {e}")
1226
+ return 1
1227
+
1228
+ # Define searchable sections
1229
+ searchable_sections = {
1230
+ 'invariants': data.get('invariants', {}),
1231
+ 'assumptions': data.get('assumptions', []),
1232
+ 'known_issues': data.get('known_issues', []),
1233
+ 'architecture': data.get('architecture', {}),
1234
+ 'critical_paths': data.get('critical_paths', []),
1235
+ 'roadmap': data.get('roadmap', []),
1236
+ 'changes': data.get('changes', []),
1237
+ 'performance': data.get('performance', {}),
1238
+ 'security': data.get('security', {}),
1239
+ 'dependencies': data.get('dependencies', {}),
1240
+ 'testing': data.get('testing', {}),
1241
+ 'deployment': data.get('deployment', {}),
1242
+ 'team': data.get('team', {})
1243
+ }
1244
+
1245
+ # Filter by section if specified
1246
+ if section:
1247
+ if section not in searchable_sections:
1248
+ print(f"❌ Error: Unknown section '{section}'")
1249
+ print(f" Available sections: {', '.join(searchable_sections.keys())}")
1250
+ return 1
1251
+ searchable_sections = {section: searchable_sections[section]}
1252
+
1253
+ # Search function
1254
+ def search_value(value, path=""):
1255
+ """Recursively search through data structures."""
1256
+ results = []
1257
+
1258
+ if isinstance(value, str):
1259
+ if pattern.search(value):
1260
+ results.append((path, value))
1261
+ elif isinstance(value, list):
1262
+ for i, item in enumerate(value):
1263
+ results.extend(search_value(item, f"{path}[{i}]"))
1264
+ elif isinstance(value, dict):
1265
+ for key, val in value.items():
1266
+ new_path = f"{path}.{key}" if path else key
1267
+ results.extend(search_value(val, new_path))
1268
+
1269
+ return results
1270
+
1271
+ # Perform search
1272
+ all_results = []
1273
+ for section_name, section_data in searchable_sections.items():
1274
+ results = search_value(section_data, section_name)
1275
+ all_results.extend(results)
1276
+
1277
+ # Display results
1278
+ if not all_results:
1279
+ print(f"🔍 No results found for: {query}")
1280
+ if section:
1281
+ print(f" (searched in: {section})")
1282
+ return 0
1283
+
1284
+ if format == 'json':
1285
+ # JSON output
1286
+ output = {
1287
+ 'query': query,
1288
+ 'section': section,
1289
+ 'count': len(all_results),
1290
+ 'results': [{'path': path, 'value': value} for path, value in all_results]
1291
+ }
1292
+ print(json.dumps(output, indent=2))
1293
+
1294
+ elif format == 'yaml':
1295
+ # YAML output
1296
+ import yaml
1297
+ output = {
1298
+ 'query': query,
1299
+ 'section': section,
1300
+ 'count': len(all_results),
1301
+ 'results': [{'path': path, 'value': value} for path, value in all_results]
1302
+ }
1303
+ print(yaml.dump(output, default_flow_style=False))
1304
+
1305
+ else:
1306
+ # Text output (default)
1307
+ print(f"🔍 Query Results: '{query}'")
1308
+ if section:
1309
+ print(f" Section: {section}")
1310
+ print(f" Found: {len(all_results)} match(es)")
1311
+ print()
1312
+
1313
+ for path, value in all_results:
1314
+ print(f"📍 {path}")
1315
+ # Highlight the match
1316
+ highlighted = pattern.sub(lambda m: f"**{m.group()}**", value)
1317
+ print(f" {highlighted}")
1318
+ print()
1319
+
1320
+ return 0
1321
+
1322
+ except Exception as e:
1323
+ print(f"❌ Error querying context: {e}")
1324
+ import traceback
1325
+ traceback.print_exc()
1326
+ return 1
1327
+
1328
+
1329
+ def get_git_commit_hash() -> Optional[str]:
1330
+ """
1331
+ Get the current Git commit hash.
1332
+
1333
+ Returns:
1334
+ Commit hash (short form) or None if not in a Git repository
1335
+ """
1336
+ try:
1337
+ import subprocess
1338
+ result = subprocess.run(
1339
+ ['git', 'rev-parse', '--short', 'HEAD'],
1340
+ capture_output=True,
1341
+ text=True,
1342
+ check=True
1343
+ )
1344
+ return result.stdout.strip()
1345
+ except (subprocess.CalledProcessError, FileNotFoundError):
1346
+ return None
1347
+
1348
+
1349
+ def get_git_author() -> Optional[str]:
1350
+ """
1351
+ Get the Git author name and email.
1352
+
1353
+ Returns:
1354
+ Author string in format "Name <email>" or None if not in a Git repository
1355
+ """
1356
+ try:
1357
+ import subprocess
1358
+ result = subprocess.run(
1359
+ ['git', 'config', 'user.name'],
1360
+ capture_output=True,
1361
+ text=True,
1362
+ check=True
1363
+ )
1364
+ name = result.stdout.strip()
1365
+
1366
+ result = subprocess.run(
1367
+ ['git', 'config', 'user.email'],
1368
+ capture_output=True,
1369
+ text=True,
1370
+ check=True
1371
+ )
1372
+ email = result.stdout.strip()
1373
+
1374
+ if name and email:
1375
+ return f"{name} <{email}>"
1376
+ elif name:
1377
+ return name
1378
+ else:
1379
+ return None
1380
+ except (subprocess.CalledProcessError, FileNotFoundError):
1381
+ return None
1382
+
1383
+
1384
+ def is_git_repository() -> bool:
1385
+ """
1386
+ Check if the current directory is inside a Git repository.
1387
+
1388
+ Returns:
1389
+ True if in a Git repository, False otherwise
1390
+ """
1391
+ try:
1392
+ import subprocess
1393
+ subprocess.run(
1394
+ ['git', 'rev-parse', '--git-dir'],
1395
+ capture_output=True,
1396
+ check=True
1397
+ )
1398
+ return True
1399
+ except (subprocess.CalledProcessError, FileNotFoundError):
1400
+ return False
1401
+
1402
+
1403
+
1404
+ def cmd_context_check(
1405
+ module_filter: Optional[str] = None,
1406
+ invariant_filter: Optional[str] = None,
1407
+ output_format: str = 'text'
1408
+ ) -> int:
1409
+ """
1410
+ Check that code respects documented invariants.
1411
+
1412
+ Args:
1413
+ module_filter: Optional module name to filter checks
1414
+ invariant_filter: Optional invariant text to filter checks
1415
+ output_format: Output format ('text', 'json')
1416
+
1417
+ Returns:
1418
+ Exit code (0 for all passed, 1 for violations found, 2 for error)
1419
+ """
1420
+ if not context_file_exists():
1421
+ print("❌ Error: No .voodocs.context file found in this directory.")
1422
+ print(" Run 'voodocs context init' to create one.")
1423
+ return 2
1424
+
1425
+ try:
1426
+ from .checker import InvariantChecker, ViolationSeverity
1427
+ import json
1428
+
1429
+ # Read context
1430
+ context_path = get_context_file_path()
1431
+ data = read_context_yaml(context_path)
1432
+
1433
+ if not data:
1434
+ print("❌ Error: Failed to read .voodocs.context file.")
1435
+ return 2
1436
+
1437
+ # Get invariants
1438
+ invariants_data = data.get('invariants', {})
1439
+ global_invariants = invariants_data.get('global', [])
1440
+
1441
+ if not global_invariants:
1442
+ print("⚠️ No invariants found in context file.")
1443
+ print(" Add invariants to the 'invariants.global' section.")
1444
+ return 0
1445
+
1446
+ # Filter invariants if specified
1447
+ if invariant_filter:
1448
+ global_invariants = [
1449
+ inv for inv in global_invariants
1450
+ if invariant_filter.lower() in inv.lower()
1451
+ ]
1452
+
1453
+ if not global_invariants:
1454
+ print(f"⚠️ No invariants match filter: {invariant_filter}")
1455
+ return 0
1456
+
1457
+ # Get source directory
1458
+ source_dir = get_project_root()
1459
+
1460
+ # Initialize checker
1461
+ checker = InvariantChecker()
1462
+
1463
+ if output_format == 'text':
1464
+ print("🔍 Checking Invariants")
1465
+ print()
1466
+
1467
+ # Check each invariant
1468
+ results = checker.check_invariants(
1469
+ global_invariants,
1470
+ source_dir,
1471
+ module_filter
1472
+ )
1473
+
1474
+ # Output results
1475
+ if output_format == 'json':
1476
+ output = {
1477
+ 'total': len(results),
1478
+ 'passed': sum(1 for r in results if r.passed),
1479
+ 'failed': sum(1 for r in results if not r.passed),
1480
+ 'results': []
1481
+ }
1482
+
1483
+ for result in results:
1484
+ output['results'].append({
1485
+ 'invariant': result.invariant,
1486
+ 'passed': result.passed,
1487
+ 'checked_files': result.checked_files,
1488
+ 'violations': [
1489
+ {
1490
+ 'file': v.file_path,
1491
+ 'line': v.line_number,
1492
+ 'content': v.line_content,
1493
+ 'severity': v.severity.value,
1494
+ 'explanation': v.explanation
1495
+ }
1496
+ for v in result.violations
1497
+ ]
1498
+ })
1499
+
1500
+ print(json.dumps(output, indent=2))
1501
+
1502
+ else:
1503
+ # Text output
1504
+ passed_count = 0
1505
+ failed_count = 0
1506
+ warning_count = 0
1507
+
1508
+ for result in results:
1509
+ if result.passed:
1510
+ passed_count += 1
1511
+ print(f"✅ {result.invariant}")
1512
+ print(f" No violations found (checked {result.checked_files} files)")
1513
+ print()
1514
+ else:
1515
+ # Count by severity
1516
+ errors = [v for v in result.violations if v.severity == ViolationSeverity.ERROR]
1517
+ warnings = [v for v in result.violations if v.severity == ViolationSeverity.WARNING]
1518
+ infos = [v for v in result.violations if v.severity == ViolationSeverity.INFO]
1519
+
1520
+ if errors:
1521
+ failed_count += 1
1522
+ print(f"❌ {result.invariant}")
1523
+ elif warnings:
1524
+ warning_count += 1
1525
+ print(f"⚠️ {result.invariant}")
1526
+ else:
1527
+ print(f"ℹ️ {result.invariant}")
1528
+
1529
+ print(f" Found {len(result.violations)} potential violation(s):")
1530
+ print()
1531
+
1532
+ # Show first 5 violations
1533
+ for violation in result.violations[:5]:
1534
+ print(f" 📍 {violation.file_path}:{violation.line_number}")
1535
+ print(f" {violation.line_content}")
1536
+ print(f" → {violation.explanation}")
1537
+ print()
1538
+
1539
+ if len(result.violations) > 5:
1540
+ print(f" ... and {len(result.violations) - 5} more")
1541
+ print()
1542
+
1543
+ # Summary
1544
+ print("=" * 60)
1545
+ print("Summary:")
1546
+ print(f" ✅ Passed: {passed_count}")
1547
+ if failed_count > 0:
1548
+ print(f" ❌ Failed: {failed_count}")
1549
+ if warning_count > 0:
1550
+ print(f" ⚠️ Warnings: {warning_count}")
1551
+ print()
1552
+
1553
+ # Return exit code
1554
+ if any(not r.passed for r in results):
1555
+ return 1
1556
+ return 0
1557
+
1558
+ except ImportError as e:
1559
+ print(f"❌ Error: Failed to import checker: {e}")
1560
+ return 2
1561
+ except Exception as e:
1562
+ print(f"❌ Error checking invariants: {e}")
1563
+ import traceback
1564
+ traceback.print_exc()
1565
+ return 2
1566
+
1567
+
1568
+
1569
+ def cmd_context_diagram(
1570
+ diagram_type: str = 'modules',
1571
+ output_format: str = 'mermaid',
1572
+ output_file: Optional[str] = None,
1573
+ render_png: bool = False
1574
+ ) -> int:
1575
+ """
1576
+ Generate architecture diagrams from context.
1577
+
1578
+ Args:
1579
+ diagram_type: Type of diagram ('modules', 'dependencies', 'flow', 'all')
1580
+ output_format: Output format ('mermaid', 'd2')
1581
+ output_file: Output file path (optional)
1582
+ render_png: Render to PNG using manus-render-diagram
1583
+
1584
+ Returns:
1585
+ Exit code (0 for success, 1 for error)
1586
+ """
1587
+ if not context_file_exists():
1588
+ print("❌ Error: No .voodocs.context file found in this directory.")
1589
+ print(" Run 'voodocs context init' to create one.")
1590
+ return 1
1591
+
1592
+ try:
1593
+ from .diagram import DiagramGenerator
1594
+
1595
+ # Read context
1596
+ context_path = get_context_file_path()
1597
+ data = read_context_yaml(context_path)
1598
+
1599
+ if not data:
1600
+ print("❌ Error: Failed to read .voodocs.context file.")
1601
+ return 1
1602
+
1603
+ # Initialize generator
1604
+ generator = DiagramGenerator()
1605
+
1606
+ # Generate diagrams
1607
+ diagrams = {}
1608
+
1609
+ if diagram_type in ['modules', 'all']:
1610
+ print(f"📊 Generating module diagram...")
1611
+ diagrams['modules'] = generator.generate_module_diagram(data, output_format)
1612
+
1613
+ if diagram_type in ['dependencies', 'all']:
1614
+ print(f"📊 Generating dependency diagram...")
1615
+ diagrams['dependencies'] = generator.generate_dependency_diagram(data, output_format)
1616
+
1617
+ if diagram_type in ['flow', 'all']:
1618
+ print(f"📊 Generating flow diagram...")
1619
+ diagrams['flow'] = generator.generate_flow_diagram(data, output_format)
1620
+
1621
+ if not diagrams:
1622
+ print(f"❌ Error: Unknown diagram type: {diagram_type}")
1623
+ print(" Valid types: modules, dependencies, flow, all")
1624
+ return 1
1625
+
1626
+ # Output diagrams
1627
+ if output_file:
1628
+ output_path = Path(output_file)
1629
+
1630
+ if diagram_type == 'all':
1631
+ # Save multiple diagrams
1632
+ for dtype, diagram_source in diagrams.items():
1633
+ dtype_path = output_path.parent / f"{output_path.stem}_{dtype}{output_path.suffix}"
1634
+
1635
+ if render_png:
1636
+ # Render to PNG
1637
+ png_path = dtype_path.with_suffix('.png')
1638
+ success = generator.render_to_png(diagram_source, png_path, output_format)
1639
+
1640
+ if success:
1641
+ print(f"✅ {dtype.capitalize()} diagram saved to: {png_path}")
1642
+ else:
1643
+ print(f"❌ Failed to render {dtype} diagram")
1644
+ # Still save source
1645
+ dtype_path.write_text(diagram_source)
1646
+ print(f" Source saved to: {dtype_path}")
1647
+ else:
1648
+ # Save source only
1649
+ dtype_path.write_text(diagram_source)
1650
+ print(f"✅ {dtype.capitalize()} diagram saved to: {dtype_path}")
1651
+ else:
1652
+ # Save single diagram
1653
+ if render_png:
1654
+ # Render to PNG
1655
+ png_path = output_path.with_suffix('.png')
1656
+ success = generator.render_to_png(diagrams[diagram_type], png_path, output_format)
1657
+
1658
+ if success:
1659
+ print(f"✅ Diagram saved to: {png_path}")
1660
+ else:
1661
+ print(f"❌ Failed to render diagram")
1662
+ # Still save source
1663
+ output_path.write_text(diagrams[diagram_type])
1664
+ print(f" Source saved to: {output_path}")
1665
+ else:
1666
+ # Save source only
1667
+ output_path.write_text(diagrams[diagram_type])
1668
+ print(f"✅ Diagram saved to: {output_path}")
1669
+ else:
1670
+ # Print to console
1671
+ for dtype, diagram_source in diagrams.items():
1672
+ if len(diagrams) > 1:
1673
+ print(f"\n{'=' * 60}")
1674
+ print(f"{dtype.upper()} DIAGRAM")
1675
+ print('=' * 60)
1676
+ print(diagram_source)
1677
+ print()
1678
+
1679
+ return 0
1680
+
1681
+ except ImportError as e:
1682
+ print(f"❌ Error: Failed to import diagram generator: {e}")
1683
+ return 1
1684
+ except Exception as e:
1685
+ print(f"❌ Error generating diagram: {e}")
1686
+ import traceback
1687
+ traceback.print_exc()
1688
+ return 1