Skip to content

Commit c763272

Browse files
feat: cross-repo awareness — tracks API contracts between microservices
When service A changes an endpoint, codebase-intel flags every service that calls it, with risk assessment and actionable recommendations. New module: crossrepo/ - models.py: ServiceInfo, APIEndpoint, ServiceDependency, CrossRepoImpact - scanner.py: discovers endpoints (route decorators) and outbound HTTP calls (httpx, requests, aiohttp) from source code - registry.py: matches consumers to providers, resolves dependencies, computes cross-service impact, persists to YAML New CLI command: codebase-intel crossrepo service-a/ service-b/ service-c/ codebase-intel crossrepo service-a/ service-b/ --impact service-a Tested on 5 production microservices: - 162 endpoints discovered across all services - 307 outbound calls detected - 3 cross-service dependencies resolved - Impact analysis: changing /v1/jds in job-marketing-ai affects 2 other services (resume-builder and course-module)
1 parent aeb4084 commit c763272

5 files changed

Lines changed: 1008 additions & 0 deletions

File tree

src/codebase_intel/cli/main.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,93 @@ async def _detect_patterns_async(project_root: Path, save: bool) -> None:
428428
console.print("\nRun with `--save` to generate a draft contract from these patterns.")
429429

430430

431+
# -------------------------------------------------------------------
432+
# crossrepo
433+
# -------------------------------------------------------------------
434+
435+
436+
@app.command(name="crossrepo")
437+
def crossrepo(
438+
paths: Annotated[
439+
list[Path],
440+
typer.Argument(help="Paths to service repos (space-separated)"),
441+
],
442+
impact: str = typer.Option("", "--impact", "-i", help="Service ID to check impact for"),
443+
verbose: bool = typer.Option(False, "--verbose", "-v"),
444+
) -> None:
445+
"""Scan multiple repos and map cross-service dependencies."""
446+
_setup_logging(verbose)
447+
448+
from codebase_intel.crossrepo.registry import CrossRepoRegistry
449+
450+
registry_dir = paths[0].resolve().parent / ".codebase-intel-crossrepo"
451+
registry = CrossRepoRegistry(registry_dir)
452+
453+
# Scan all services
454+
for repo_path in paths:
455+
resolved = repo_path.resolve()
456+
if not resolved.is_dir():
457+
console.print(f"[red]Not a directory: {resolved}[/red]")
458+
continue
459+
460+
with Progress(
461+
SpinnerColumn(),
462+
TextColumn("[progress.description]{task.description}"),
463+
console=console,
464+
) as progress:
465+
task = progress.add_task(f"Scanning {resolved.name}...", total=None)
466+
result = registry.register_service(resolved)
467+
progress.update(task, description=f"Done: {result.to_dict()['endpoints']} endpoints", completed=True)
468+
469+
# Resolve dependencies
470+
console.print()
471+
deps = registry.resolve_dependencies()
472+
console.print(f"[green]Resolved {len(deps)} cross-service dependencies[/green]")
473+
474+
# Show service map
475+
service_map = registry.get_service_map()
476+
table = Table(title="Service Dependency Map")
477+
table.add_column("Service", style="bold")
478+
table.add_column("Endpoints", justify="right")
479+
table.add_column("Outbound Calls", justify="right")
480+
table.add_column("Depends On", style="cyan")
481+
table.add_column("Depended On By", style="yellow")
482+
483+
for sid, info in service_map.items():
484+
table.add_row(
485+
sid,
486+
str(info["endpoints_exposed"]),
487+
str(info["outbound_calls"]),
488+
", ".join(info["depends_on"]) or "—",
489+
", ".join(info["depended_on_by"]) or "—",
490+
)
491+
console.print(table)
492+
493+
# Impact analysis if requested
494+
if impact:
495+
impacts = registry.impact_analysis(impact)
496+
if impacts:
497+
console.print()
498+
for imp in impacts:
499+
risk_color = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "green"}.get(imp.risk_level, "white")
500+
console.print(Panel(
501+
f"Endpoint: [bold]{imp.changed_endpoint}[/bold]\n"
502+
f"Risk: [{risk_color}]{imp.risk_level.upper()}[/{risk_color}] — "
503+
f"{imp.affected_count} services affected\n\n"
504+
f"{imp.recommendation}",
505+
title=f"Impact: {impact}",
506+
))
507+
for dep in imp.affected_services:
508+
critical = " [red][CRITICAL][/red]" if dep.is_critical else ""
509+
console.print(f" → {dep.consumer_service}{critical} at {dep.file_path}:{dep.line_number or '?'}")
510+
else:
511+
console.print(f"[green]No cross-service impact found for {impact}[/green]")
512+
513+
# Save registry
514+
saved = registry.save()
515+
console.print(f"\n[dim]Registry saved to {saved}[/dim]")
516+
517+
431518
# -------------------------------------------------------------------
432519
# serve
433520
# -------------------------------------------------------------------
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Cross-repo awareness — tracks API contracts and dependencies between services."""
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

0 commit comments

Comments
 (0)