mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Adds a shared HanzoCloud client (core/cloud.py; lazy httpx, api.hanzo.ai /v1) and wires the cloud half of the hybrid tools: code gains cross-repo search/context/ask/index over the cloud index, vector gains a cloud-backed store plus an infinity-embedded local option, net gains a vision tool. Bumps hanzo-tools 0.3.2->0.3.3, -code 0.1.2->0.1.3, -net 0.1.2->0.1.3, -vector 0.2.0->0.2.1 and declares httpx where the cloud path is used. New tests cover the code/net cloud actions. import hanzo_tools.core is green.
966 lines
36 KiB
Python
966 lines
36 KiB
Python
"""Unified code semantics tool for HIP-0300 architecture.
|
|
|
|
This module provides a single unified 'code' tool that handles all semantic code operations:
|
|
- parse: Parse source to AST (tree-sitter)
|
|
- serialize: AST back to text
|
|
- symbols: List symbols in file/scope
|
|
- definition: Go to definition (LSP)
|
|
- references: Find all references (LSP)
|
|
- transform: Pure codemod → Patch (no side effects)
|
|
- summarize: Compress Diff/Log/Report to summary
|
|
|
|
Following Unix philosophy: one tool for the Symbols + Structure axis.
|
|
All operations are PURE (no side effects) except where noted.
|
|
|
|
Effect lattice position: PURE
|
|
Representation: Text → AST → Patch
|
|
Scope: File → Package → Repo
|
|
"""
|
|
|
|
import difflib
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar, Literal
|
|
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_tools.core import (
|
|
BaseTool,
|
|
HanzoCloud,
|
|
InvalidParamsError,
|
|
NotFoundError,
|
|
ToolError,
|
|
content_hash,
|
|
)
|
|
|
|
# Language detection by extension
|
|
LANG_MAP = {
|
|
".py": "python",
|
|
".js": "javascript",
|
|
".ts": "typescript",
|
|
".tsx": "tsx",
|
|
".jsx": "jsx",
|
|
".go": "go",
|
|
".rs": "rust",
|
|
".c": "c",
|
|
".cpp": "cpp",
|
|
".h": "c",
|
|
".hpp": "cpp",
|
|
".java": "java",
|
|
".rb": "ruby",
|
|
".php": "php",
|
|
".swift": "swift",
|
|
".kt": "kotlin",
|
|
".scala": "scala",
|
|
".cs": "c_sharp",
|
|
".lua": "lua",
|
|
".sh": "bash",
|
|
".bash": "bash",
|
|
".zsh": "bash",
|
|
".json": "json",
|
|
".yaml": "yaml",
|
|
".yml": "yaml",
|
|
".toml": "toml",
|
|
".md": "markdown",
|
|
".html": "html",
|
|
".css": "css",
|
|
".sql": "sql",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TransformSpec:
|
|
"""Specification for code transformation."""
|
|
|
|
kind: Literal["rename", "extract", "inline", "move", "codemod", "generate"]
|
|
|
|
# For rename
|
|
old_name: str | None = None
|
|
new_name: str | None = None
|
|
scope: Literal["file", "package", "repo"] = "file"
|
|
|
|
# For codemod (pattern-based)
|
|
match_pattern: str | None = None
|
|
replace_template: str | None = None
|
|
|
|
# For generate
|
|
template: str | None = None
|
|
context: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class CodeTool(BaseTool):
|
|
"""Unified code semantics tool (HIP-0300).
|
|
|
|
Handles all semantic code operations on a single axis:
|
|
- parse: Parse source to AST
|
|
- serialize: AST to text
|
|
- symbols: List symbols
|
|
- definition: Go to definition
|
|
- references: Find references
|
|
- transform: Pure codemod → Patch
|
|
- summarize: Compress to summary
|
|
|
|
All operations are PURE (safe to parallelize/cache).
|
|
"""
|
|
|
|
name: ClassVar[str] = "code"
|
|
VERSION: ClassVar[str] = "0.1.0"
|
|
|
|
def __init__(self, cwd: str | None = None):
|
|
super().__init__()
|
|
self.cwd = cwd or os.getcwd()
|
|
self._tree_sitter = None
|
|
self._lsp_clients: dict[str, Any] = {}
|
|
self._cloud: HanzoCloud | None = None
|
|
self._register_code_actions()
|
|
self._register_cloud_actions()
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return """Unified code semantics tool (HIP-0300). Hybrid: local AST + cloud index.
|
|
|
|
Local (tree-sitter/LSP, instant, single-file — PURE):
|
|
- parse: Parse source code to AST
|
|
- serialize: Convert AST back to text
|
|
- symbols: List symbols in file/scope
|
|
- definition: Go to symbol definition (LSP)
|
|
- references: Find all references to symbol (LSP)
|
|
- transform: Pure codemod producing Patch (no side effects)
|
|
- summarize: Compress Diff/Log/Report to summary
|
|
- search_symbol/outline/metrics/exports/types/hierarchy/rename/grep_replace
|
|
|
|
Cloud (api.hanzo.ai /v1/code, cross-repo semantic index over real Qdrant):
|
|
- search: Cross-repo code search (type: symbol|text|semantic|hybrid)
|
|
- context: Budget-packed context bundle for a query
|
|
- ask: Cited RAG answer over indexed code
|
|
- index: (Re)index files/dirs into the cloud code index
|
|
"""
|
|
|
|
def _detect_lang(self, path: str | None, text: str | None = None) -> str:
|
|
"""Detect language from path extension or content."""
|
|
if path:
|
|
ext = Path(path).suffix.lower()
|
|
if ext in LANG_MAP:
|
|
return LANG_MAP[ext]
|
|
|
|
# Fallback: try to detect from shebang or content
|
|
if text:
|
|
first_line = text.split("\n", 1)[0]
|
|
if first_line.startswith("#!"):
|
|
if "python" in first_line:
|
|
return "python"
|
|
elif "node" in first_line or "deno" in first_line:
|
|
return "javascript"
|
|
elif "bash" in first_line or "sh" in first_line:
|
|
return "bash"
|
|
|
|
return "unknown"
|
|
|
|
def _get_tree_sitter(self):
|
|
"""Lazy-load tree-sitter."""
|
|
if self._tree_sitter is None:
|
|
# Try tree-sitter-language-pack first (works with tree-sitter 0.24+)
|
|
try:
|
|
import tree_sitter_language_pack
|
|
self._tree_sitter = tree_sitter_language_pack
|
|
except ImportError:
|
|
try:
|
|
import tree_sitter_languages
|
|
self._tree_sitter = tree_sitter_languages
|
|
except ImportError:
|
|
raise ToolError(
|
|
code="INTERNAL_ERROR",
|
|
message="tree-sitter-languages not installed. Run: pip install tree-sitter-language-pack",
|
|
)
|
|
return self._tree_sitter
|
|
|
|
def _parse_with_tree_sitter(self, text: str, lang: str) -> dict:
|
|
"""Parse text to AST using tree-sitter."""
|
|
ts = self._get_tree_sitter()
|
|
|
|
try:
|
|
parser = ts.get_parser(lang)
|
|
tree = parser.parse(text.encode())
|
|
|
|
def node_to_dict(node) -> dict:
|
|
result = {
|
|
"type": node.type,
|
|
"start": {"line": node.start_point[0], "col": node.start_point[1]},
|
|
"end": {"line": node.end_point[0], "col": node.end_point[1]},
|
|
}
|
|
if node.child_count > 0:
|
|
result["children"] = [node_to_dict(c) for c in node.children]
|
|
else:
|
|
result["text"] = node.text.decode() if node.text else ""
|
|
return result
|
|
|
|
return {
|
|
"root": node_to_dict(tree.root_node),
|
|
"lang": lang,
|
|
"errors": [
|
|
{
|
|
"line": n.start_point[0],
|
|
"col": n.start_point[1],
|
|
"message": "syntax error",
|
|
}
|
|
for n in tree.root_node.children
|
|
if n.type == "ERROR"
|
|
],
|
|
}
|
|
except Exception as e:
|
|
raise ToolError(
|
|
code="INTERNAL_ERROR",
|
|
message=f"Parse error: {e}",
|
|
details={"lang": lang},
|
|
)
|
|
|
|
def _extract_symbols(self, ast: dict, lang: str) -> list[dict]:
|
|
"""Extract symbols from AST."""
|
|
symbols = []
|
|
|
|
# Symbol node types by language
|
|
symbol_types = {
|
|
"python": ["function_definition", "class_definition", "assignment"],
|
|
"javascript": [
|
|
"function_declaration",
|
|
"class_declaration",
|
|
"variable_declaration",
|
|
"lexical_declaration",
|
|
],
|
|
"typescript": [
|
|
"function_declaration",
|
|
"class_declaration",
|
|
"interface_declaration",
|
|
"type_alias_declaration",
|
|
],
|
|
"go": ["function_declaration", "method_declaration", "type_declaration"],
|
|
"rust": [
|
|
"function_item",
|
|
"struct_item",
|
|
"enum_item",
|
|
"impl_item",
|
|
"trait_item",
|
|
],
|
|
}
|
|
|
|
types_to_find = symbol_types.get(
|
|
lang, ["function_definition", "class_definition"]
|
|
)
|
|
|
|
def walk(node: dict, parent_name: str = ""):
|
|
node_type = node.get("type", "")
|
|
|
|
if node_type in types_to_find:
|
|
# Try to extract name from first identifier child
|
|
name = None
|
|
for child in node.get("children", []):
|
|
if child.get("type") == "identifier":
|
|
name = child.get("text", "")
|
|
break
|
|
elif child.get("type") == "name":
|
|
name = child.get("text", "")
|
|
break
|
|
|
|
if name:
|
|
symbols.append(
|
|
{
|
|
"name": name,
|
|
"kind": node_type,
|
|
"range": {
|
|
"start": node["start"],
|
|
"end": node["end"],
|
|
},
|
|
"parent": parent_name or None,
|
|
}
|
|
)
|
|
parent_name = name
|
|
|
|
for child in node.get("children", []):
|
|
walk(child, parent_name)
|
|
|
|
if "root" in ast:
|
|
walk(ast["root"])
|
|
|
|
return symbols
|
|
|
|
def _generate_patch(self, original: str, modified: str, path: str = "file") -> str:
|
|
"""Generate unified diff patch."""
|
|
original_lines = original.splitlines(keepends=True)
|
|
modified_lines = modified.splitlines(keepends=True)
|
|
|
|
diff = difflib.unified_diff(
|
|
original_lines,
|
|
modified_lines,
|
|
fromfile=f"a/{path}",
|
|
tofile=f"b/{path}",
|
|
)
|
|
|
|
return "".join(diff)
|
|
|
|
def _apply_rename(self, text: str, old_name: str, new_name: str) -> tuple[str, int]:
|
|
"""Apply simple rename transformation. Returns (new_text, count)."""
|
|
import re
|
|
|
|
# Word-boundary rename to avoid partial matches
|
|
pattern = rf"\b{re.escape(old_name)}\b"
|
|
new_text, count = re.subn(pattern, new_name, text)
|
|
return new_text, count
|
|
|
|
def _apply_codemod(self, text: str, match: str, replace: str) -> tuple[str, int]:
|
|
"""Apply pattern-based codemod. Returns (new_text, count)."""
|
|
import re
|
|
|
|
try:
|
|
new_text, count = re.subn(match, replace, text)
|
|
return new_text, count
|
|
except re.error as e:
|
|
raise InvalidParamsError(
|
|
f"Invalid regex pattern: {e}", param="match_pattern"
|
|
)
|
|
|
|
def _get_cloud(self) -> HanzoCloud:
|
|
"""Lazily build the shared Hanzo Cloud client for /v1/code/*."""
|
|
if self._cloud is None:
|
|
self._cloud = HanzoCloud()
|
|
if not self._cloud.configured():
|
|
raise ToolError(
|
|
code="INVALID_PARAMS",
|
|
message="Hanzo Cloud not configured: set HANZO_API_KEY "
|
|
"or add .apiKey to ~/.hanzo/config.json",
|
|
)
|
|
return self._cloud
|
|
|
|
def _collect_index_files(
|
|
self, path: str, max_files: int, max_bytes: int
|
|
) -> list[dict]:
|
|
"""Read a file or walk a directory into index payload records."""
|
|
root = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
if not root.exists():
|
|
raise NotFoundError(f"Path not found: {path}", uri=str(root))
|
|
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
files: list[dict] = []
|
|
|
|
candidates = [root] if root.is_file() else sorted(root.rglob("*"))
|
|
for p in candidates:
|
|
if len(files) >= max_files:
|
|
break
|
|
if p.is_dir() or any(sd in p.parts for sd in skip_dirs):
|
|
continue
|
|
if p.suffix not in LANG_MAP:
|
|
continue
|
|
try:
|
|
if p.stat().st_size > max_bytes:
|
|
continue
|
|
content = p.read_text(errors="ignore")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
rel = str(p.relative_to(root)) if root.is_dir() else p.name
|
|
files.append({"path": rel, "content": content})
|
|
return files
|
|
|
|
def _register_cloud_actions(self):
|
|
"""Register cloud-backed actions calling api.hanzo.ai /v1/code/*.
|
|
|
|
The cloud index is the cross-repo, semantic half of the hybrid: local
|
|
AST answers single-file questions instantly; these answer questions that
|
|
span repos (real Qdrant vectors + zen embeddings, server-side).
|
|
"""
|
|
|
|
@self.action("search", "Cross-repo code search via the cloud index")
|
|
async def search(
|
|
ctx: MCPContext,
|
|
query: str,
|
|
type: str = "hybrid",
|
|
repo: str | None = None,
|
|
limit: int = 20,
|
|
) -> dict:
|
|
"""Search indexed code across repos.
|
|
|
|
Args:
|
|
query: Search query
|
|
type: symbol | text | semantic | hybrid
|
|
repo: Restrict to a single repo (optional)
|
|
limit: Max results
|
|
|
|
Returns:
|
|
{query, type, results: [{repo, file, line, endLine, kind, symbol, snippet, score}]}
|
|
|
|
Effect: NONDETERMINISTIC_EFFECT (cloud I/O)
|
|
"""
|
|
data = await self._get_cloud().get(
|
|
"/v1/code/search",
|
|
{"q": query, "type": type, "repo": repo, "limit": limit},
|
|
)
|
|
return data
|
|
|
|
@self.action("context", "Budget-packed context bundle for a query")
|
|
async def context(
|
|
ctx: MCPContext,
|
|
query: str,
|
|
budgetTokens: int = 4000,
|
|
repo: str | None = None,
|
|
) -> dict:
|
|
"""Get a token-budgeted bundle of the most relevant code spans.
|
|
|
|
Returns:
|
|
{query, budgetTokens, usedTokens, spans: [...]}
|
|
"""
|
|
body: dict[str, Any] = {"query": query, "budgetTokens": budgetTokens}
|
|
if repo:
|
|
body["repo"] = repo
|
|
return await self._get_cloud().post("/v1/code/context", body)
|
|
|
|
@self.action("ask", "Cited RAG answer over indexed code")
|
|
async def ask(
|
|
ctx: MCPContext,
|
|
query: str,
|
|
repo: str | None = None,
|
|
) -> dict:
|
|
"""Ask a question over the code index; get a cited answer.
|
|
|
|
Returns:
|
|
{question, answer, citations: [{repo, file, line, endLine, symbol}]}
|
|
"""
|
|
body: dict[str, Any] = {"query": query}
|
|
if repo:
|
|
body["repo"] = repo
|
|
return await self._get_cloud().post("/v1/code/ask", body)
|
|
|
|
@self.action("index", "Index files/dir into the cloud code index")
|
|
async def index(
|
|
ctx: MCPContext,
|
|
repo: str,
|
|
path: str | None = None,
|
|
files: list[dict] | None = None,
|
|
prune: bool = False,
|
|
max_files: int = 2000,
|
|
max_bytes: int = 1_000_000,
|
|
) -> dict:
|
|
"""(Re)index code into the cloud index (incremental, content-hashed).
|
|
|
|
Provide either an explicit `files` list ([{path, content}]) or a
|
|
`path` (file or directory) to read and index.
|
|
|
|
Returns:
|
|
{repo, indexed, skipped, pruned, files, symbols, chunks, vectors, semantic}
|
|
"""
|
|
payload_files = files
|
|
if payload_files is None:
|
|
if not path:
|
|
raise InvalidParamsError(
|
|
"index requires either 'files' or 'path'", param="path"
|
|
)
|
|
payload_files = self._collect_index_files(path, max_files, max_bytes)
|
|
if not payload_files:
|
|
raise InvalidParamsError("no indexable files found", param="path")
|
|
|
|
return await self._get_cloud().post(
|
|
"/v1/code/index",
|
|
{"repo": repo, "files": payload_files, "prune": prune},
|
|
)
|
|
|
|
def _register_code_actions(self):
|
|
"""Register all code actions."""
|
|
|
|
@self.action("parse", "Parse source code to AST")
|
|
async def parse(
|
|
ctx: MCPContext,
|
|
path: str | None = None,
|
|
text: str | None = None,
|
|
lang: str | None = None,
|
|
) -> dict:
|
|
"""Parse source to AST using tree-sitter.
|
|
|
|
Args:
|
|
path: File path to parse
|
|
text: Raw text to parse (alternative to path)
|
|
lang: Language (auto-detected if not specified)
|
|
|
|
Returns:
|
|
AST structure with lang and errors
|
|
|
|
Effect: PURE
|
|
Cache: hash(text)
|
|
"""
|
|
if path and not text:
|
|
full_path = (
|
|
Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
)
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}", uri=str(full_path))
|
|
text = full_path.read_text()
|
|
|
|
if not text:
|
|
raise InvalidParamsError("Either path or text required", param="text")
|
|
|
|
detected_lang = lang or self._detect_lang(path, text)
|
|
|
|
ast = self._parse_with_tree_sitter(text, detected_lang)
|
|
|
|
return {
|
|
"ast": ast,
|
|
"lang": detected_lang,
|
|
"hash": content_hash(text),
|
|
"source_map": {"path": path} if path else None,
|
|
}
|
|
|
|
@self.action("serialize", "Convert AST back to text")
|
|
async def serialize(
|
|
ctx: MCPContext,
|
|
ast: dict,
|
|
lang: str | None = None,
|
|
) -> dict:
|
|
"""Serialize AST back to text.
|
|
|
|
Note: This is a simplified implementation that extracts
|
|
leaf node text. Full round-trip requires CST preservation.
|
|
|
|
Effect: PURE
|
|
"""
|
|
|
|
def extract_text(node: dict) -> str:
|
|
if "text" in node:
|
|
return node["text"]
|
|
children_text = []
|
|
for child in node.get("children", []):
|
|
children_text.append(extract_text(child))
|
|
return " ".join(children_text)
|
|
|
|
root = ast.get("root") or ast
|
|
text = extract_text(root)
|
|
|
|
return {
|
|
"text": text,
|
|
"lang": lang or ast.get("lang", "unknown"),
|
|
}
|
|
|
|
@self.action("symbols", "List symbols in file")
|
|
async def symbols(
|
|
ctx: MCPContext,
|
|
path: str | None = None,
|
|
text: str | None = None,
|
|
ast: dict | None = None,
|
|
lang: str | None = None,
|
|
) -> dict:
|
|
"""Extract symbols from source code.
|
|
|
|
Args:
|
|
path: File path
|
|
text: Raw text (alternative)
|
|
ast: Pre-parsed AST (alternative)
|
|
lang: Language hint
|
|
|
|
Returns:
|
|
List of symbols with name, kind, range
|
|
|
|
Effect: PURE
|
|
"""
|
|
if ast is None:
|
|
if path and not text:
|
|
full_path = (
|
|
Path(path)
|
|
if Path(path).is_absolute()
|
|
else Path(self.cwd) / path
|
|
)
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}")
|
|
text = full_path.read_text()
|
|
|
|
if not text:
|
|
raise InvalidParamsError("path, text, or ast required")
|
|
|
|
detected_lang = lang or self._detect_lang(path, text)
|
|
parsed = self._parse_with_tree_sitter(text, detected_lang)
|
|
ast = parsed
|
|
lang = detected_lang
|
|
|
|
symbols_list = self._extract_symbols(
|
|
ast, lang or ast.get("lang", "unknown")
|
|
)
|
|
|
|
return {
|
|
"symbols": symbols_list,
|
|
"count": len(symbols_list),
|
|
"path": path,
|
|
}
|
|
|
|
@self.action("definition", "Go to symbol definition")
|
|
async def definition(
|
|
ctx: MCPContext,
|
|
path: str,
|
|
line: int,
|
|
col: int,
|
|
) -> dict:
|
|
"""Find definition of symbol at position.
|
|
|
|
Uses LSP when available, falls back to AST search.
|
|
|
|
Effect: PURE
|
|
"""
|
|
# TODO: Integrate with LSP (gopls, pyright, etc.)
|
|
# For now, return a stub indicating LSP integration needed
|
|
return {
|
|
"path": path,
|
|
"position": {"line": line, "col": col},
|
|
"definition": None,
|
|
"note": "LSP integration required for full definition lookup",
|
|
}
|
|
|
|
@self.action("references", "Find all references to symbol")
|
|
async def references(
|
|
ctx: MCPContext,
|
|
path: str,
|
|
line: int,
|
|
col: int,
|
|
scope: str = "repo",
|
|
) -> dict:
|
|
"""Find all references to symbol at position.
|
|
|
|
Uses LSP when available, falls back to text search.
|
|
|
|
Effect: PURE
|
|
Scope: file | package | repo
|
|
"""
|
|
# TODO: Integrate with LSP
|
|
return {
|
|
"path": path,
|
|
"position": {"line": line, "col": col},
|
|
"references": [],
|
|
"scope": scope,
|
|
"note": "LSP integration required for full reference lookup",
|
|
}
|
|
|
|
@self.action("transform", "Generate patch from transformation spec")
|
|
async def transform(
|
|
ctx: MCPContext,
|
|
path: str | None = None,
|
|
text: str | None = None,
|
|
kind: str = "rename",
|
|
old_name: str | None = None,
|
|
new_name: str | None = None,
|
|
match_pattern: str | None = None,
|
|
replace_template: str | None = None,
|
|
scope: str = "file",
|
|
) -> dict:
|
|
"""Apply transformation and produce Patch (PURE - no side effects).
|
|
|
|
This is the key "pure middle layer" operator:
|
|
- Input: source + transform spec
|
|
- Output: Patch as value (not applied)
|
|
|
|
Kinds:
|
|
rename: Rename symbol (old_name → new_name)
|
|
codemod: Pattern-based replacement
|
|
|
|
Effect: PURE
|
|
"""
|
|
# Load text if path provided
|
|
if path and not text:
|
|
full_path = (
|
|
Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
)
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}")
|
|
text = full_path.read_text()
|
|
|
|
if not text:
|
|
raise InvalidParamsError("path or text required")
|
|
|
|
original_hash = content_hash(text)
|
|
original = text
|
|
changes_count = 0
|
|
|
|
if kind == "rename":
|
|
if not old_name or not new_name:
|
|
raise InvalidParamsError("rename requires old_name and new_name")
|
|
text, changes_count = self._apply_rename(text, old_name, new_name)
|
|
|
|
elif kind == "codemod":
|
|
if not match_pattern or replace_template is None:
|
|
raise InvalidParamsError(
|
|
"codemod requires match_pattern and replace_template"
|
|
)
|
|
text, changes_count = self._apply_codemod(
|
|
text, match_pattern, replace_template
|
|
)
|
|
|
|
else:
|
|
raise InvalidParamsError(
|
|
f"Unknown transform kind: {kind}",
|
|
param="kind",
|
|
expected="rename | codemod",
|
|
)
|
|
|
|
# Generate patch
|
|
patch = self._generate_patch(original, text, path or "input")
|
|
|
|
return {
|
|
"patch": patch,
|
|
"changes_count": changes_count,
|
|
"base_hash": original_hash,
|
|
"new_hash": content_hash(text) if changes_count > 0 else original_hash,
|
|
"kind": kind,
|
|
"report": {
|
|
"files_affected": 1 if changes_count > 0 else 0,
|
|
"lines_changed": patch.count("\n") if patch else 0,
|
|
},
|
|
}
|
|
|
|
@self.action("summarize", "Compress diff/log/report to summary")
|
|
async def summarize(
|
|
ctx: MCPContext,
|
|
diff: str | None = None,
|
|
log: list[dict] | None = None,
|
|
report: dict | None = None,
|
|
text: str | None = None,
|
|
max_length: int = 500,
|
|
) -> dict:
|
|
"""Summarize code artifacts for review.
|
|
|
|
Takes one of: diff, log, report, or text
|
|
Produces: summary, risks, next_actions
|
|
|
|
Effect: PURE
|
|
"""
|
|
summary_parts = []
|
|
risks = []
|
|
next_actions = []
|
|
|
|
if diff:
|
|
# Analyze diff
|
|
lines = diff.split("\n")
|
|
adds = sum(
|
|
1
|
|
for line in lines
|
|
if line.startswith("+") and not line.startswith("+++")
|
|
)
|
|
dels = sum(
|
|
1
|
|
for line in lines
|
|
if line.startswith("-") and not line.startswith("---")
|
|
)
|
|
files = [line[6:] for line in lines if line.startswith("+++ b/")]
|
|
|
|
summary_parts.append(
|
|
f"Patch: +{adds}/-{dels} lines across {len(files)} file(s)"
|
|
)
|
|
|
|
if adds + dels > 100:
|
|
risks.append("Large change - review carefully")
|
|
if any("test" in f.lower() for f in files):
|
|
next_actions.append("Run tests to verify")
|
|
else:
|
|
risks.append("No test files modified")
|
|
next_actions.append("Consider adding tests")
|
|
|
|
if log:
|
|
summary_parts.append(f"History: {len(log)} commit(s)")
|
|
if log:
|
|
latest = (
|
|
log[0] if isinstance(log[0], dict) else {"message": str(log[0])}
|
|
)
|
|
summary_parts.append(f"Latest: {latest.get('message', 'N/A')[:50]}")
|
|
|
|
if report:
|
|
if "pass" in report:
|
|
status = "PASS" if report["pass"] else "FAIL"
|
|
summary_parts.append(f"Status: {status}")
|
|
if not report["pass"]:
|
|
risks.append("Tests failing")
|
|
next_actions.append("Fix failing tests before merge")
|
|
|
|
if text:
|
|
# Simple text summary
|
|
word_count = len(text.split())
|
|
summary_parts.append(f"Text: {word_count} words")
|
|
if word_count > 1000:
|
|
summary_parts.append("(truncated for summary)")
|
|
|
|
return {
|
|
"summary": (
|
|
" | ".join(summary_parts)
|
|
if summary_parts
|
|
else "No content to summarize"
|
|
),
|
|
"risks": risks,
|
|
"next_actions": next_actions,
|
|
}
|
|
|
|
# ── TS-parity actions ──────────────────────────────────────────
|
|
|
|
@self.action("search_symbol", "Find symbols across project")
|
|
async def search_symbol(
|
|
ctx: MCPContext,
|
|
query: str,
|
|
path: str | None = None,
|
|
max_results: int = 20,
|
|
) -> dict:
|
|
import re
|
|
search_dir = Path(path) if path else Path(self.cwd)
|
|
results = []
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
for p in search_dir.rglob("*"):
|
|
if len(results) >= max_results:
|
|
break
|
|
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
|
continue
|
|
try:
|
|
text = p.read_text(errors="ignore")
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
if query in line and re.search(r'\b(def|class|function|fn|struct|enum|interface|type|const|let|var|pub)\b', line):
|
|
results.append({"uri": str(p), "line": i, "text": line.strip(), "type": "definition"})
|
|
if len(results) >= max_results:
|
|
break
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
return {"query": query, "results": results, "count": len(results)}
|
|
|
|
@self.action("outline", "List symbols with import count and export flag")
|
|
async def outline(ctx: MCPContext, path: str, text: str | None = None) -> dict:
|
|
import re
|
|
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
if text is None:
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}")
|
|
text = full_path.read_text()
|
|
lines = text.splitlines()
|
|
syms = []
|
|
imports = 0
|
|
sym_re = re.compile(r'(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var|def|fn|pub\s+fn|pub\s+struct|struct|impl)\s+(\w+)')
|
|
imp_re = re.compile(r'^(?:import|from|require|use)\b')
|
|
for i, line in enumerate(lines, 1):
|
|
if imp_re.match(line.strip()):
|
|
imports += 1
|
|
m = sym_re.search(line)
|
|
if m:
|
|
kind_m = re.search(r'\b(class|interface|type|enum|function|const|struct|impl|fn|def)\b', line)
|
|
syms.append({"name": m.group(1), "kind": kind_m.group(1) if kind_m else "symbol", "line": i, "exported": line.strip().startswith(("export ", "pub "))})
|
|
return {"uri": str(full_path), "symbols": syms, "imports": imports, "lines": len(lines)}
|
|
|
|
@self.action("metrics", "Count files and lines by extension")
|
|
async def metrics(ctx: MCPContext, path: str | None = None) -> dict:
|
|
search_dir = Path(path) if path else Path(self.cwd)
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
by_ext: dict[str, dict[str, int]] = {}
|
|
total_files = 0
|
|
total_lines = 0
|
|
for p in search_dir.rglob("*"):
|
|
if any(sd in p.parts for sd in skip_dirs) or not p.is_file():
|
|
continue
|
|
ext = p.suffix or "other"
|
|
try:
|
|
lines = len(p.read_text(errors="ignore").splitlines())
|
|
if ext not in by_ext:
|
|
by_ext[ext] = {"files": 0, "lines": 0}
|
|
by_ext[ext]["files"] += 1
|
|
by_ext[ext]["lines"] += lines
|
|
total_files += 1
|
|
total_lines += lines
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
return {"total_files": total_files, "total_lines": total_lines, "by_extension": by_ext}
|
|
|
|
@self.action("exports", "Extract public exports from file")
|
|
async def exports(ctx: MCPContext, path: str) -> dict:
|
|
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}")
|
|
text = full_path.read_text()
|
|
export_lines = [line.strip() for line in text.splitlines() if line.strip().startswith(("export ", "pub ", "__all__"))]
|
|
return {"uri": str(full_path), "exports": export_lines, "count": len(export_lines)}
|
|
|
|
@self.action("types", "Find type definitions in file")
|
|
async def types(ctx: MCPContext, path: str) -> dict:
|
|
import re
|
|
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
|
if not full_path.exists():
|
|
raise NotFoundError(f"File not found: {path}")
|
|
text = full_path.read_text()
|
|
type_defs = []
|
|
type_re = re.compile(r'(?:interface|type|enum|struct|class)\s+(\w+)')
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
m = type_re.search(line)
|
|
if m:
|
|
type_defs.append({"name": m.group(1), "line": i, "text": line.strip()})
|
|
return {"uri": str(full_path), "types": type_defs, "count": len(type_defs)}
|
|
|
|
@self.action("hierarchy", "Build class inheritance tree")
|
|
async def hierarchy(ctx: MCPContext, query: str, path: str | None = None) -> dict:
|
|
import re
|
|
search_dir = Path(path) if path else Path(self.cwd)
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
classes: dict[str, list[str]] = {}
|
|
class_re = re.compile(r'class\s+(\w+)(?:\s*\(([^)]+)\)|\s+extends\s+(\w+))?')
|
|
for p in search_dir.rglob("*"):
|
|
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
|
continue
|
|
try:
|
|
for m in class_re.finditer(p.read_text(errors="ignore")):
|
|
name = m.group(1)
|
|
parent = m.group(3) or (m.group(2).split(",")[0].strip() if m.group(2) else None)
|
|
if name not in classes:
|
|
classes[name] = []
|
|
if parent and parent not in ("object", "Object"):
|
|
classes.setdefault(parent, []).append(name)
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
def build(n: str, d: int = 0) -> str:
|
|
out = " " * d + n + "\n"
|
|
for c in classes.get(n, []):
|
|
out += build(c, d + 1)
|
|
return out
|
|
return {"root": query, "tree": build(query), "children": classes.get(query, [])}
|
|
|
|
@self.action("rename", "Rename symbol across files")
|
|
async def rename(ctx: MCPContext, query: str, new_name: str, path: str | None = None) -> dict:
|
|
import re
|
|
search_dir = Path(path) if path else Path(self.cwd)
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
total_changes = 0
|
|
changed = []
|
|
word_re = re.compile(rf'\b{re.escape(query)}\b')
|
|
for p in search_dir.rglob("*"):
|
|
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
|
continue
|
|
try:
|
|
text = p.read_text(errors="ignore")
|
|
if word_re.search(text):
|
|
count = len(word_re.findall(text))
|
|
p.write_text(word_re.sub(new_name, text))
|
|
total_changes += count
|
|
changed.append(f"{p}: {count} replacements")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
return {"old_name": query, "new_name": new_name, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
|
|
|
|
@self.action("grep_replace", "Pattern replacement across codebase")
|
|
async def grep_replace(ctx: MCPContext, query: str, replacement: str, path: str | None = None) -> dict:
|
|
import re as re_mod
|
|
search_dir = Path(path) if path else Path(self.cwd)
|
|
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
|
total_changes = 0
|
|
changed = []
|
|
try:
|
|
regex = re_mod.compile(query)
|
|
except re_mod.error as e:
|
|
raise InvalidParamsError(f"Invalid regex: {e}", param="query")
|
|
for p in search_dir.rglob("*"):
|
|
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
|
continue
|
|
try:
|
|
text = p.read_text(errors="ignore")
|
|
if regex.search(text):
|
|
count = len(regex.findall(text))
|
|
p.write_text(regex.sub(replacement, text))
|
|
total_changes += count
|
|
changed.append(f"{p}: {count}")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
return {"pattern": query, "replacement": replacement, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
|
|
|
|
|
|
# Singleton instance
|
|
code_tool = CodeTool
|