113 lines
3.3 KiB
Python
113 lines
3.3 KiB
Python
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Optional, Literal, Any
|
|
|
|
from pydantic import field_validator, BaseModel, Field
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RepoContext:
|
|
url: str
|
|
default_branch: str
|
|
language_hint: str | None # "python" | "typescript" | "go" | None
|
|
|
|
class Language(str, Enum):
|
|
PYTHON = "python"
|
|
JAVASCRIPT = "javascript"
|
|
TYPESCRIPT = "typescript"
|
|
JAVA = "java"
|
|
GO = "go"
|
|
RUST = "rust"
|
|
CPP = "cpp"
|
|
C = "c"
|
|
|
|
LanguageLiteral = Literal[
|
|
Language.PYTHON,
|
|
Language.JAVASCRIPT,
|
|
Language.TYPESCRIPT,
|
|
Language.JAVA,
|
|
Language.GO,
|
|
Language.RUST,
|
|
Language.CPP,
|
|
Language.C,
|
|
]
|
|
|
|
class DependencyNode(BaseModel):
|
|
"""A single node in the dependency graph."""
|
|
id: str = Field(..., description="Unique identifier (file path or module name)")
|
|
label: str = Field(..., description="Human-readable label")
|
|
language: Language
|
|
is_external: bool = Field(
|
|
default=False,
|
|
description="True if the dependency is outside the repository",
|
|
)
|
|
|
|
|
|
class DependencyEdge(BaseModel):
|
|
"""A directed edge from source → target (source depends on target)."""
|
|
source: str = Field(..., description="Node id of the dependant")
|
|
target: str = Field(..., description="Node id of the dependency")
|
|
kind: str = Field(
|
|
default="import",
|
|
description="Edge kind: import, include, inherit, call, etc.",
|
|
)
|
|
|
|
|
|
class DependencyGraph(BaseModel):
|
|
"""Complete dependency graph result."""
|
|
root: str = Field(..., description="Entrypoint node id")
|
|
nodes: list[DependencyNode]
|
|
edges: list[DependencyEdge]
|
|
metadata: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class ToolRequirement(BaseModel):
|
|
"""Describes a required external tool and how to install it."""
|
|
tool_name: str
|
|
command: str = Field(..., description="CLI command that must be on PATH")
|
|
install_guide: str = Field(..., description="Markdown installation instructions")
|
|
docs_url: Optional[str] = None
|
|
|
|
|
|
class AnalysisError(BaseModel):
|
|
"""Structured error returned when analysis cannot proceed."""
|
|
error_code: str
|
|
message: str
|
|
missing_tools: list[ToolRequirement] = Field(default_factory=list)
|
|
language: Optional[Language] = None
|
|
entrypoint: Optional[str] = None
|
|
|
|
|
|
class AnalysisInput(BaseModel):
|
|
"""Input schema for the analyzer."""
|
|
repo_path: Path = Field(..., description="Absolute path to the cloned repository")
|
|
language: Language
|
|
entrypoint: str = Field(
|
|
...,
|
|
description="Relative path from repo_path to the entrypoint file",
|
|
)
|
|
|
|
@field_validator("repo_path")
|
|
@classmethod
|
|
def repo_must_exist(cls, v: Path) -> Path:
|
|
if not v.is_dir():
|
|
raise ValueError(f"Repository path does not exist or is not a directory: {v}")
|
|
return v.resolve()
|
|
|
|
@field_validator("entrypoint")
|
|
@classmethod
|
|
def entrypoint_must_exist(cls, v: str, info) -> str:
|
|
repo = info.data.get("repo_path")
|
|
if repo and not (repo / v).is_file():
|
|
raise ValueError(f"Entrypoint not found: {repo / v}")
|
|
return v
|
|
|
|
|
|
class AnalysisResult(BaseModel):
|
|
"""Top-level result — either a graph or an error."""
|
|
success: bool
|
|
graph: Optional[DependencyGraph] = None
|
|
error: Optional[AnalysisError] = None
|
|
leaf_clusters: Any
|
|
res_all: Any |