codebase-intel 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -428,6 +428,114 @@ async def _detect_patterns_async(project_root: Path, save: bool) -> None:
428
428
  console.print("\nRun with `--save` to generate a draft contract from these patterns.")
429
429
 
430
430
 
431
+ # -------------------------------------------------------------------
432
+ # crossrepo
433
+ # -------------------------------------------------------------------
434
+
435
+
436
+ @app.command(name="crossrepo")
437
+ def crossrepo(
438
+ paths: Annotated[
439
+ Optional[list[Path]],
440
+ typer.Argument(help="Paths to service repos (space-separated). Omit with --all."),
441
+ ] = None,
442
+ all_registered: bool = typer.Option(False, "--all", "-A", help="Scan all globally registered projects"),
443
+ impact: str = typer.Option("", "--impact", "-i", help="Service ID to check impact for"),
444
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
445
+ ) -> None:
446
+ """Scan multiple repos and map cross-service dependencies.
447
+
448
+ Provide paths explicitly or use --all to scan all registered projects:
449
+ codebase-intel crossrepo /svc1 /svc2 /svc3
450
+ codebase-intel crossrepo --all
451
+ """
452
+ _setup_logging(verbose)
453
+
454
+ from codebase_intel.crossrepo.registry import CrossRepoRegistry
455
+
456
+ # Resolve paths: explicit args or global registry
457
+ if all_registered:
458
+ from codebase_intel.workspace.registry import GlobalRegistry
459
+
460
+ global_registry = GlobalRegistry()
461
+ resolved_paths = global_registry.get_all_paths()
462
+ if not resolved_paths:
463
+ console.print("[red]No projects registered.[/red] Use `codebase-intel register` first.")
464
+ raise typer.Exit(1)
465
+ console.print(f"[cyan]Scanning {len(resolved_paths)} registered project(s)...[/cyan]")
466
+ elif paths:
467
+ resolved_paths = [p.resolve() for p in paths]
468
+ else:
469
+ console.print("[red]Provide repo paths or use --all to scan registered projects.[/red]")
470
+ raise typer.Exit(1)
471
+
472
+ registry_dir = resolved_paths[0].parent / ".codebase-intel-crossrepo"
473
+ registry = CrossRepoRegistry(registry_dir)
474
+
475
+ # Scan all services
476
+ for resolved in resolved_paths:
477
+ if not resolved.is_dir():
478
+ console.print(f"[red]Not a directory: {resolved}[/red]")
479
+ continue
480
+
481
+ with Progress(
482
+ SpinnerColumn(),
483
+ TextColumn("[progress.description]{task.description}"),
484
+ console=console,
485
+ ) as progress:
486
+ task = progress.add_task(f"Scanning {resolved.name}...", total=None)
487
+ result = registry.register_service(resolved)
488
+ progress.update(task, description=f"Done: {result.to_dict()['endpoints']} endpoints", completed=True)
489
+
490
+ # Resolve dependencies
491
+ console.print()
492
+ deps = registry.resolve_dependencies()
493
+ console.print(f"[green]Resolved {len(deps)} cross-service dependencies[/green]")
494
+
495
+ # Show service map
496
+ service_map = registry.get_service_map()
497
+ table = Table(title="Service Dependency Map")
498
+ table.add_column("Service", style="bold")
499
+ table.add_column("Endpoints", justify="right")
500
+ table.add_column("Outbound Calls", justify="right")
501
+ table.add_column("Depends On", style="cyan")
502
+ table.add_column("Depended On By", style="yellow")
503
+
504
+ for sid, info in service_map.items():
505
+ table.add_row(
506
+ sid,
507
+ str(info["endpoints_exposed"]),
508
+ str(info["outbound_calls"]),
509
+ ", ".join(info["depends_on"]) or "—",
510
+ ", ".join(info["depended_on_by"]) or "—",
511
+ )
512
+ console.print(table)
513
+
514
+ # Impact analysis if requested
515
+ if impact:
516
+ impacts = registry.impact_analysis(impact)
517
+ if impacts:
518
+ console.print()
519
+ for imp in impacts:
520
+ risk_color = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "green"}.get(imp.risk_level, "white")
521
+ console.print(Panel(
522
+ f"Endpoint: [bold]{imp.changed_endpoint}[/bold]\n"
523
+ f"Risk: [{risk_color}]{imp.risk_level.upper()}[/{risk_color}] — "
524
+ f"{imp.affected_count} services affected\n\n"
525
+ f"{imp.recommendation}",
526
+ title=f"Impact: {impact}",
527
+ ))
528
+ for dep in imp.affected_services:
529
+ critical = " [red][CRITICAL][/red]" if dep.is_critical else ""
530
+ console.print(f" → {dep.consumer_service}{critical} at {dep.file_path}:{dep.line_number or '?'}")
531
+ else:
532
+ console.print(f"[green]No cross-service impact found for {impact}[/green]")
533
+
534
+ # Save registry
535
+ saved = registry.save()
536
+ console.print(f"\n[dim]Registry saved to {saved}[/dim]")
537
+
538
+
431
539
  # -------------------------------------------------------------------
432
540
  # serve
433
541
  # -------------------------------------------------------------------
@@ -435,15 +543,234 @@ async def _detect_patterns_async(project_root: Path, save: bool) -> None:
435
543
 
436
544
  @app.command()
437
545
  def serve(
438
- path: Annotated[Path, typer.Argument(help="Project root")] = Path("."),
546
+ path: Annotated[Optional[Path], typer.Argument(help="Project root (omit for --auto mode)")] = None,
547
+ auto: bool = typer.Option(False, "--auto", "-a", help="Workspace-aware mode: serve all registered projects"),
439
548
  verbose: bool = typer.Option(False, "--verbose", "-v"),
440
549
  ) -> None:
441
- """Start the MCP server (stdio transport)."""
550
+ """Start the MCP server (stdio transport).
551
+
552
+ Single-project mode (default):
553
+ codebase-intel serve /path/to/project
554
+
555
+ Multi-project mode (global):
556
+ codebase-intel serve --auto
557
+
558
+ In --auto mode, the server routes each request to the correct project
559
+ based on file paths. Register projects first with `codebase-intel register`.
560
+ """
442
561
  _setup_logging(verbose)
443
- console.print("[dim]Starting MCP server over stdio...[/dim]", err=True)
444
562
 
445
563
  from codebase_intel.mcp.server import run_server
446
- asyncio.run(run_server(path.resolve()))
564
+
565
+ if auto:
566
+ console.print("[dim]Starting MCP server in workspace-aware mode...[/dim]", err=True)
567
+ asyncio.run(run_server(auto_mode=True))
568
+ else:
569
+ project_root = (path or Path(".")).resolve()
570
+ console.print(f"[dim]Starting MCP server for {project_root.name}...[/dim]", err=True)
571
+ asyncio.run(run_server(project_root))
572
+
573
+
574
+ # -------------------------------------------------------------------
575
+ # register / unregister / projects (global workspace)
576
+ # -------------------------------------------------------------------
577
+
578
+
579
+ @app.command()
580
+ def register(
581
+ path: Annotated[Path, typer.Argument(help="Project root to register")] = Path("."),
582
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Display name for the project"),
583
+ project_id: Optional[str] = typer.Option(None, "--id", help="Custom project ID (default: directory name)"),
584
+ ) -> None:
585
+ """Register a project in the global registry.
586
+
587
+ Once registered, the project is available to `codebase-intel serve --auto`
588
+ and `codebase-intel crossrepo scan --all`.
589
+
590
+ Examples:
591
+ codebase-intel register /path/to/user-service
592
+ codebase-intel register . --name "Payment API" --id payment-api
593
+ """
594
+ from codebase_intel.workspace.registry import GlobalRegistry
595
+
596
+ registry = GlobalRegistry()
597
+ resolved = path.resolve()
598
+
599
+ if not resolved.is_dir():
600
+ console.print(f"[red]Not a directory: {resolved}[/red]")
601
+ raise typer.Exit(1)
602
+
603
+ entry = registry.register(resolved, project_id=project_id, name=name)
604
+
605
+ init_status = "[green]initialized[/green]" if entry.initialized else "[yellow]not initialized[/yellow]"
606
+ console.print(
607
+ f"[green]Registered[/green] [bold]{entry.project_id}[/bold] "
608
+ f"→ {entry.root_path} ({init_status})"
609
+ )
610
+
611
+ if not entry.initialized:
612
+ console.print(
613
+ f"[dim]Run `codebase-intel init {resolved}` to build the code graph.[/dim]"
614
+ )
615
+
616
+
617
+ @app.command()
618
+ def unregister(
619
+ project_id: Annotated[str, typer.Argument(help="Project ID to remove")],
620
+ ) -> None:
621
+ """Remove a project from the global registry."""
622
+ from codebase_intel.workspace.registry import GlobalRegistry
623
+
624
+ registry = GlobalRegistry()
625
+
626
+ if registry.unregister(project_id):
627
+ console.print(f"[green]Unregistered[/green] [bold]{project_id}[/bold]")
628
+ else:
629
+ console.print(f"[red]Project '{project_id}' not found in registry[/red]")
630
+ raise typer.Exit(1)
631
+
632
+
633
+ @app.command()
634
+ def projects() -> None:
635
+ """List all globally registered projects."""
636
+ from codebase_intel.workspace.registry import GlobalRegistry
637
+
638
+ registry = GlobalRegistry()
639
+ all_projects = registry.get_all()
640
+
641
+ if not all_projects:
642
+ console.print("[yellow]No projects registered.[/yellow]")
643
+ console.print("Register with: `codebase-intel register /path/to/project`")
644
+ return
645
+
646
+ table = Table(title="Registered Projects")
647
+ table.add_column("ID", style="bold")
648
+ table.add_column("Name")
649
+ table.add_column("Path")
650
+ table.add_column("Status")
651
+ table.add_column("Initialized")
652
+ table.add_column("Last Accessed", style="dim")
653
+
654
+ for entry in all_projects:
655
+ exists = entry.is_valid
656
+ status = "[green]OK[/green]" if exists else "[red]MISSING[/red]"
657
+ init_status = "[green]Yes[/green]" if entry.initialized else "[yellow]No[/yellow]"
658
+ last_accessed = (
659
+ entry.last_accessed.strftime("%Y-%m-%d %H:%M")
660
+ if entry.last_accessed
661
+ else "—"
662
+ )
663
+
664
+ table.add_row(
665
+ entry.project_id,
666
+ entry.name,
667
+ entry.root_path,
668
+ status,
669
+ init_status,
670
+ last_accessed,
671
+ )
672
+
673
+ console.print(table)
674
+ console.print(f"\n[dim]{len(all_projects)} project(s) registered. "
675
+ f"Use `codebase-intel serve --auto` to serve all.[/dim]")
676
+
677
+
678
+ # -------------------------------------------------------------------
679
+ # intent
680
+ # -------------------------------------------------------------------
681
+
682
+
683
+ @app.command()
684
+ def intent(
685
+ path: Annotated[Path, typer.Argument(help="Project root")] = Path("."),
686
+ verify: bool = typer.Option(False, "--verify", "-v", help="Run verification on all active intents"),
687
+ ) -> None:
688
+ """Show and verify tracked intents — what was requested vs what's delivered."""
689
+ asyncio.run(_intent_async(path.resolve(), verify))
690
+
691
+
692
+ async def _intent_async(project_root: Path, verify: bool) -> None:
693
+ from codebase_intel.intent.store import IntentStore
694
+ from codebase_intel.intent.verifier import IntentVerifier
695
+
696
+ intents_dir = project_root / ".codebase-intel" / "intents"
697
+ store = IntentStore(intents_dir)
698
+ all_intents = store.load_all()
699
+
700
+ if not all_intents:
701
+ console.print("[yellow]No intents tracked yet.[/yellow]")
702
+ console.print("Use the MCP tool `set_intent` to capture goals with acceptance criteria.")
703
+ return
704
+
705
+ if verify:
706
+ verifier = IntentVerifier(project_root)
707
+ console.print()
708
+
709
+ for intent_obj in all_intents:
710
+ if intent_obj.status.value not in ("active", "partial"):
711
+ continue
712
+
713
+ report = await verifier.verify(intent_obj)
714
+
715
+ # Update stored state
716
+ updated_criteria = []
717
+ for r in report.results:
718
+ updated_criteria.append(r.criterion.model_copy(update={
719
+ "verified": r.passed,
720
+ "failure_reason": r.failure_reason,
721
+ }))
722
+ store.update_criteria(intent_obj.id, updated_criteria)
723
+ store.update_status(intent_obj.id, report.status)
724
+
725
+ # Display
726
+ status_color = {
727
+ "verified": "green",
728
+ "partial": "yellow",
729
+ "failed": "red",
730
+ "active": "cyan",
731
+ }.get(report.status.value, "white")
732
+
733
+ console.print(Panel(
734
+ f"[bold]{intent_obj.title}[/bold]\n"
735
+ f"Status: [{status_color}]{report.status.value.upper()}[/{status_color}] — "
736
+ f"{report.passed_count}/{report.total} criteria met",
737
+ title=f"Intent {intent_obj.id}",
738
+ ))
739
+
740
+ for r in report.results:
741
+ icon = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]"
742
+ console.print(f" {icon} {r.criterion.description}")
743
+ if r.failure_reason:
744
+ console.print(f" [dim]{r.failure_reason}[/dim]")
745
+ console.print()
746
+
747
+ else:
748
+ # Just show status
749
+ table = Table(title="Tracked Intents")
750
+ table.add_column("ID", style="dim")
751
+ table.add_column("Title", style="bold")
752
+ table.add_column("Status")
753
+ table.add_column("Progress", justify="right")
754
+ table.add_column("Gaps", justify="right", style="red")
755
+
756
+ for i in all_intents:
757
+ status_color = {
758
+ "verified": "green",
759
+ "partial": "yellow",
760
+ "failed": "red",
761
+ "active": "cyan",
762
+ "abandoned": "dim",
763
+ }.get(i.status.value, "white")
764
+
765
+ table.add_row(
766
+ i.id,
767
+ i.title[:50],
768
+ f"[{status_color}]{i.status.value}[/{status_color}]",
769
+ f"{i.criteria_met}/{i.criteria_total} ({i.completion_pct:.0f}%)",
770
+ str(len(i.gaps)),
771
+ )
772
+ console.print(table)
773
+ console.print("\nRun with `--verify` to check all criteria against the actual codebase.")
447
774
 
448
775
 
449
776
  # -------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ """Cross-repo awareness — tracks API contracts and dependencies between services."""
@@ -0,0 +1,199 @@
1
+ """Cross-repo models — schemas for service dependencies and API contracts.
2
+
3
+ In a microservice architecture, the most dangerous changes are ones that
4
+ break OTHER services. Changing an endpoint in service A silently breaks
5
+ service B, C, and D that call it. Nobody knows until production.
6
+
7
+ This module tracks:
8
+ 1. Service Registry — what services exist and where they live
9
+ 2. API Contracts — what endpoints each service exposes
10
+ 3. Service Dependencies — which services call which endpoints
11
+ 4. Impact Propagation — "if this endpoint changes, which services break?"
12
+
13
+ Edge cases:
14
+ - Service not yet indexed: discovered incrementally as repos are scanned
15
+ - Endpoint renamed: old contract marked deprecated, consumers flagged
16
+ - Service removed: all dependents get critical warnings
17
+ - Shared types diverge: contract A says field is string, contract B says int
18
+ - Async communication (queues): tracked as event contracts, not HTTP
19
+ - Multiple versions of same API: tracked with version tags
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from datetime import UTC, datetime
25
+ from enum import Enum
26
+ from typing import Any
27
+
28
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
29
+
30
+
31
+ class ServiceStatus(str, Enum):
32
+ ACTIVE = "active"
33
+ DEPRECATED = "deprecated"
34
+ REMOVED = "removed"
35
+
36
+
37
+ class ProtocolType(str, Enum):
38
+ HTTP = "http"
39
+ GRPC = "grpc"
40
+ GRAPHQL = "graphql"
41
+ EVENT = "event" # Message queue / pub-sub
42
+ INTERNAL = "internal" # Direct function call (monorepo)
43
+
44
+
45
+ class HttpMethod(str, Enum):
46
+ GET = "GET"
47
+ POST = "POST"
48
+ PUT = "PUT"
49
+ PATCH = "PATCH"
50
+ DELETE = "DELETE"
51
+ ANY = "ANY"
52
+
53
+
54
+ class ContractStatus(str, Enum):
55
+ ACTIVE = "active"
56
+ DEPRECATED = "deprecated"
57
+ BREAKING_CHANGE = "breaking_change"
58
+ REMOVED = "removed"
59
+
60
+
61
+ class ServiceInfo(BaseModel):
62
+ """A microservice in the ecosystem."""
63
+
64
+ model_config = ConfigDict(frozen=True)
65
+
66
+ service_id: str = Field(description="Unique ID: e.g., 'user-module'")
67
+ name: str = Field(description="Human name: 'User Management Service'")
68
+ repo_path: str = Field(description="Absolute path to the repo root")
69
+ status: ServiceStatus = ServiceStatus.ACTIVE
70
+ base_url_env: str = Field(
71
+ default="",
72
+ description="Env var that holds this service's base URL (e.g., USER_SERVICE_URL)",
73
+ )
74
+ tech_stack: str = Field(default="", description="e.g., 'FastAPI + Tortoise ORM'")
75
+ description: str = ""
76
+ indexed_at: datetime | None = None
77
+
78
+ @field_validator("indexed_at")
79
+ @classmethod
80
+ def ensure_utc(cls, v: datetime | None) -> datetime | None:
81
+ if v is None:
82
+ return None
83
+ if v.tzinfo is None:
84
+ return v.replace(tzinfo=UTC)
85
+ return v.astimezone(UTC)
86
+
87
+
88
+ class APIEndpoint(BaseModel):
89
+ """An API endpoint exposed by a service."""
90
+
91
+ model_config = ConfigDict(frozen=True)
92
+
93
+ path: str = Field(description="Route path: '/api/v1/users/{user_id}'")
94
+ method: HttpMethod = HttpMethod.ANY
95
+ service_id: str = Field(description="Which service exposes this")
96
+ protocol: ProtocolType = ProtocolType.HTTP
97
+
98
+ # Schema info (what the endpoint expects/returns)
99
+ request_schema: str = Field(default="", description="Pydantic model name or JSON schema ref")
100
+ response_schema: str = Field(default="", description="Pydantic model name or JSON schema ref")
101
+ description: str = ""
102
+
103
+ # Source code location
104
+ file_path: str = ""
105
+ line_number: int | None = None
106
+ function_name: str = ""
107
+
108
+ # Contract status
109
+ status: ContractStatus = ContractStatus.ACTIVE
110
+ version: str = Field(default="v1", description="API version")
111
+ deprecated_at: datetime | None = None
112
+ breaking_changes: list[str] = Field(default_factory=list)
113
+
114
+
115
+ class EventContract(BaseModel):
116
+ """An event/message published or consumed by a service."""
117
+
118
+ model_config = ConfigDict(frozen=True)
119
+
120
+ event_name: str = Field(description="Event type: 'user.created', 'order.completed'")
121
+ service_id: str
122
+ direction: str = Field(description="'publishes' or 'consumes'")
123
+ protocol: ProtocolType = ProtocolType.EVENT
124
+ payload_schema: str = ""
125
+ queue_name: str = ""
126
+ description: str = ""
127
+ file_path: str = ""
128
+
129
+
130
+ class ServiceDependency(BaseModel):
131
+ """A dependency between two services — service A calls service B."""
132
+
133
+ model_config = ConfigDict(frozen=True)
134
+
135
+ consumer_service: str = Field(description="Service that makes the call")
136
+ provider_service: str = Field(description="Service that handles the call")
137
+ endpoint_path: str = Field(description="Which endpoint is called")
138
+ method: HttpMethod = HttpMethod.ANY
139
+
140
+ # How the call is made
141
+ call_pattern: str = Field(
142
+ default="",
143
+ description="How it's called: 'httpx.get(URL)', 'requests.post()', etc.",
144
+ )
145
+ file_path: str = Field(default="", description="Where the call is made")
146
+ line_number: int | None = None
147
+
148
+ # Risk assessment
149
+ is_critical: bool = Field(
150
+ default=False,
151
+ description="True if this dependency is in a critical path (auth, payment)",
152
+ )
153
+ has_fallback: bool = Field(
154
+ default=False,
155
+ description="True if there's a fallback/retry mechanism",
156
+ )
157
+ has_circuit_breaker: bool = Field(
158
+ default=False,
159
+ description="True if circuit breaker pattern is implemented",
160
+ )
161
+
162
+
163
+ class CrossRepoImpact(BaseModel):
164
+ """Impact analysis result — what breaks across services when something changes."""
165
+
166
+ changed_service: str
167
+ changed_endpoint: str
168
+ changed_file: str = ""
169
+ affected_services: list[ServiceDependency] = Field(default_factory=list)
170
+ risk_level: str = Field(
171
+ default="low",
172
+ description="low / medium / high / critical",
173
+ )
174
+ recommendation: str = ""
175
+
176
+ @property
177
+ def affected_count(self) -> int:
178
+ return len(self.affected_services)
179
+
180
+ def to_context_string(self) -> str:
181
+ lines = [
182
+ f"## Cross-Service Impact: {self.changed_service}",
183
+ f"Changed: {self.changed_endpoint}",
184
+ f"Risk: **{self.risk_level.upper()}** — {self.affected_count} services affected",
185
+ "",
186
+ ]
187
+ for dep in self.affected_services:
188
+ critical = " [CRITICAL]" if dep.is_critical else ""
189
+ fallback = " (has fallback)" if dep.has_fallback else " (NO fallback)"
190
+ lines.append(
191
+ f"- **{dep.consumer_service}** calls this endpoint{critical}{fallback}"
192
+ )
193
+ if dep.file_path:
194
+ lines.append(f" at {dep.file_path}:{dep.line_number or '?'}")
195
+
196
+ if self.recommendation:
197
+ lines.append(f"\n**Recommendation:** {self.recommendation}")
198
+
199
+ return "\n".join(lines)