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