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.
- codebase_intel/cli/main.py +331 -4
- codebase_intel/crossrepo/__init__.py +1 -0
- codebase_intel/crossrepo/models.py +199 -0
- codebase_intel/crossrepo/registry.py +372 -0
- codebase_intel/crossrepo/scanner.py +471 -0
- codebase_intel/intent/__init__.py +1 -0
- codebase_intel/intent/models.py +185 -0
- codebase_intel/intent/store.py +187 -0
- codebase_intel/intent/verifier.py +362 -0
- codebase_intel/mcp/server.py +530 -11
- codebase_intel/workspace/__init__.py +13 -0
- codebase_intel/workspace/manager.py +329 -0
- codebase_intel/workspace/registry.py +242 -0
- {codebase_intel-0.1.0.dist-info → codebase_intel-0.2.0.dist-info}/METADATA +5 -5
- {codebase_intel-0.1.0.dist-info → codebase_intel-0.2.0.dist-info}/RECORD +18 -7
- {codebase_intel-0.1.0.dist-info → codebase_intel-0.2.0.dist-info}/WHEEL +0 -0
- {codebase_intel-0.1.0.dist-info → codebase_intel-0.2.0.dist-info}/entry_points.txt +0 -0
- {codebase_intel-0.1.0.dist-info → codebase_intel-0.2.0.dist-info}/licenses/LICENSE +0 -0
codebase_intel/cli/main.py
CHANGED
|
@@ -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")] =
|
|
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
|
-
|
|
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)
|