diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c21c08..be6514d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.12', '3.13'] + python-version: ['3.12'] steps: - uses: actions/checkout@v4 diff --git a/agentic-framework/pyproject.toml b/agentic-framework/pyproject.toml index bf043ab..1dd415c 100644 --- a/agentic-framework/pyproject.toml +++ b/agentic-framework/pyproject.toml @@ -27,6 +27,8 @@ dependencies = [ "langchain-core>=1.1.3", "typer>=0.12.0", "rich>=13.0.0", + "tree-sitter==0.21.3", + "tree-sitter-languages>=1.10.2", ] [project.scripts] diff --git a/agentic-framework/src/agentic_framework/core/developer_agent.py b/agentic-framework/src/agentic_framework/core/developer_agent.py index cae3bb0..01d7907 100644 --- a/agentic-framework/src/agentic_framework/core/developer_agent.py +++ b/agentic-framework/src/agentic_framework/core/developer_agent.py @@ -7,6 +7,7 @@ from agentic_framework.registry import AgentRegistry from agentic_framework.tools import ( CodeSearcher, + FileEditorTool, FileFinderTool, FileFragmentReaderTool, FileOutlinerTool, @@ -24,23 +25,66 @@ class DeveloperAgent(LangGraphMCPAgent): @property def system_prompt(self) -> str: return """You are a Principal Software Engineer assistant. - Your goal is to help the user understand and maintain their codebase. - You have access to several specialized tools for: - 1. Discovering the project structure and finding files by name (`find_files`). - 2. Extracting class and function outlines from code files (`get_file_outline`). - Supports: Python, JavaScript, TypeScript, Rust, Go, Java, C/C++, PHP. - 3. Reading specific fragments of a file (`read_file_fragment`). - 4. Searching the codebase for patterns using ripgrep (`code_search`). - - When you need to find a specific file by name, use `find_files`. - When asked about the project structure, start with `discover_structure`. - When asked to explain a file, start with `get_file_outline` to get an overview. - Use `read_file_fragment` to read specific lines if you need more detail. - Use `code_search` for fast global pattern matching. - - Always provide clear, concise explanations and suggest improvements when relevant. - You also have access to MCP tools like `webfetch` if you need to fetch information from the web. - """ +Your goal is to help the user understand and maintain their codebase. + +## AVAILABLE TOOLS + +1. **find_files** - Fast file search by name using fd +2. **discover_structure** - Directory tree exploration +3. **get_file_outline** - Extract class/function signatures (Python, JS, TS, Rust, Go, Java, C/C++, PHP) +4. **read_file_fragment** - Read specific line ranges (format: 'path:start:end') +5. **code_search** - Fast pattern search via ripgrep +6. **edit_file** - Edit files with line-based or text-based operations +7. **webfetch** (MCP) - Fetch web content + +## MANDATORY FILE EDITING WORKFLOW + +Before using edit_file, you MUST follow this sequence: + +### Step 1: READ FIRST (Required) +- Use `read_file_fragment` to see the EXACT lines you plan to modify +- NEVER guess line numbers - always verify them first +- Example: read_file_fragment("example.py:88:95") + +### Step 2: COPY EXACTLY +When providing replacement content: +- Copy the EXACT text including all quotes, indentation, and punctuation +- Do NOT truncate, paraphrase, or summarize +- Preserve docstring delimiters (\"\"\" or \'\'\') +- Maintain exact indentation (tabs vs spaces) + +### Step 3: APPLY EDIT +Use edit_file with the appropriate format: + +Line-based operations: +- replace:path:start:end:content +- insert:path:after_line:content +- delete:path:start:end + +Text-based operation (RECOMMENDED - no line numbers needed): +{"op": "search_replace", "path": "file.py", "old": "exact text to find", "new": "replacement text"} + +### Step 4: VERIFY +After editing: +- Check the result message for SYNTAX WARNING +- If warning appears, read the affected lines and fix immediately +- Do not exceed 3 retry attempts + +### Error Recovery +If edit_file returns an error: +1. READ the file first using read_file_fragment +2. Understand what went wrong from the error message +3. Apply a corrected edit + +## GENERAL GUIDELINES + +- When finding files by name, use `find_files` +- When exploring project structure, use `discover_structure` +- When explaining a file, start with `get_file_outline` +- Use `code_search` for fast global pattern matching + +Always provide clear, concise explanations and suggest improvements when relevant. +""" def local_tools(self) -> Sequence[Any]: # Initialize tool instances with project root @@ -49,6 +93,7 @@ def local_tools(self) -> Sequence[Any]: explorer = StructureExplorerTool(str(BASE_DIR)) outliner = FileOutlinerTool(str(BASE_DIR)) reader = FileFragmentReaderTool(str(BASE_DIR)) + editor = FileEditorTool(str(BASE_DIR)) return [ StructuredTool.from_function( @@ -76,4 +121,9 @@ def local_tools(self) -> Sequence[Any]: name=reader.name, description=reader.description, ), + StructuredTool.from_function( + func=editor.invoke, + name=editor.name, + description=editor.description, + ), ] diff --git a/agentic-framework/src/agentic_framework/tools/__init__.py b/agentic-framework/src/agentic_framework/tools/__init__.py index 6da34c0..1eaadc0 100644 --- a/agentic-framework/src/agentic_framework/tools/__init__.py +++ b/agentic-framework/src/agentic_framework/tools/__init__.py @@ -1,11 +1,13 @@ from .code_searcher import CodeSearcher from .codebase_explorer import ( + FileEditorTool, FileFinderTool, FileFragmentReaderTool, FileOutlinerTool, StructureExplorerTool, ) from .example import CalculatorTool, WeatherTool +from .syntax_validator import SyntaxValidator, ValidationResult, get_validator from .web_search import WebSearchTool __all__ = [ @@ -17,4 +19,8 @@ "FileOutlinerTool", "FileFragmentReaderTool", "FileFinderTool", + "FileEditorTool", + "SyntaxValidator", + "ValidationResult", + "get_validator", ] diff --git a/agentic-framework/src/agentic_framework/tools/codebase_explorer.py b/agentic-framework/src/agentic_framework/tools/codebase_explorer.py index f13ab0a..6a85c57 100644 --- a/agentic-framework/src/agentic_framework/tools/codebase_explorer.py +++ b/agentic-framework/src/agentic_framework/tools/codebase_explorer.py @@ -310,3 +310,425 @@ def invoke(self, pattern: str) -> Any: return relative_files except FileNotFoundError: return "Error: Required search tools (fd) not found." + + +class FileEditorTool(CodebaseExplorer, Tool): + """Tool to safely edit files with line-based operations.""" + + MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB hard limit + WARN_FILE_SIZE = 500 * 1024 # 500KB warning threshold + + # Binary file extensions to reject + BINARY_EXTENSIONS = { + ".pyc", + ".pyo", + ".so", + ".dll", + ".dylib", + ".exe", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".7z", + ".rar", + ".mp3", + ".mp4", + ".wav", + ".avi", + ".mov", + ".db", + ".sqlite", + ".sqlite3", + } + + @property + def name(self) -> str: + return "edit_file" + + @property + def description(self) -> str: + return """Edit files with line-based or text-based operations. + +Line-based operations (colon-delimited): +- replace:path:start:end:content - Replace lines start to end +- insert:path:after_line:content - Insert after line number +- insert:path:before_line:content - Insert before line number +- delete:path:start:end - Delete lines start to end + +Text-based operation (JSON format, RECOMMENDED): +{"op": "search_replace", "path": "file.py", "old": "exact text to find", "new": "replacement text"} + +The search_replace operation finds exact text and replaces it. No line numbers needed. +The old text must be unique in the file. + +IMPORTANT: Always read the file first using read_file_fragment to verify content before editing. +Line numbers are 1-indexed. Content uses \\n for newlines. +""" + + def invoke(self, input_str: str) -> Any: + """Parse and execute the edit operation.""" + try: + stripped = input_str.strip() + if stripped.startswith("{"): + return self._handle_json_input(stripped) + return self._handle_delimited_input(stripped) + except Exception as e: + return f"Error: {e}" + + def _handle_json_input(self, input_str: str) -> Any: + """Handle JSON-formatted input for complex content.""" + import json + + data = json.loads(input_str) + op = data.get("op") + path = data.get("path") + content = data.get("content", "") + + if op == "replace": + return self._replace_lines(path, data["start"], data["end"], content) + elif op == "search_replace": + return self._search_replace(path, data["old"], data.get("new", "")) + elif op == "insert": + return self._insert_lines(path, data.get("after"), data.get("before"), content) + elif op == "delete": + return self._delete_lines(path, data["start"], data["end"]) + else: + return f"Error: Unknown operation '{op}'. Supported: replace, search_replace, insert, delete" + + def _handle_delimited_input(self, input_str: str) -> Any: + """Handle colon-delimited input format.""" + op = input_str.split(":", 1)[0] + + if op == "replace": + # Format: replace:path:start:end:content + parts = input_str.split(":", 4) + if len(parts) < 5: + return "Error: Replace format: 'replace:path:start:end:content'" + path = parts[1] + start = int(parts[2]) + end = int(parts[3]) + content = parts[4].replace("\\n", "\n") + return self._replace_lines(path, start, end, content) + + elif op == "insert": + # Format: insert:path:position:content + parts = input_str.split(":", 3) + if len(parts) < 4: + return "Error: Insert format: 'insert:path:position:content'" + path = parts[1] + position = parts[2] + content = parts[3].replace("\\n", "\n") + + if position.isdigit(): + return self._insert_lines(path, after=int(position), before=None, content=content) + elif position.startswith("before_"): + line_num = int(position[7:]) + return self._insert_lines(path, after=None, before=line_num, content=content) + else: + return f"Error: Invalid insert position '{position}'" + + elif op == "delete": + # Format: delete:path:start:end + parts = input_str.split(":", 3) + if len(parts) < 4: + return "Error: Delete format: 'delete:path:start:end'" + path = parts[1] + start = int(parts[2]) + end = int(parts[3]) + return self._delete_lines(path, start, end) + + else: + return f"Error: Unknown operation '{op}'" + + def _replace_lines(self, path: str, start: int, end: int, content: str) -> str: + """Replace lines start to end (1-indexed) with content.""" + full_path = self._validate_path(path) + + if not full_path.exists(): + return f"Error: File '{path}' not found" + + warning = self._check_file_size(full_path) + + try: + with open(full_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except UnicodeDecodeError: + return f"Error: File '{path}' is not valid UTF-8 text" + + self._validate_line_bounds(lines, start, end, path) + + new_lines = lines[: start - 1] + new_lines.append(content if content.endswith("\n") else content + "\n") + new_lines.extend(lines[end:]) + + new_content = "".join(new_lines) + self._atomic_write(full_path, new_content) + + result = f"Replaced lines {start}-{end} in '{path}'" + if warning: + result = f"{warning}\n{result}" + + # Validate syntax after edit + syntax_warning = self._validate_syntax(new_content, path) + if syntax_warning: + result = f"{result}{syntax_warning}" + + return result + + def _insert_lines(self, path: str, after: Optional[int], before: Optional[int], content: str) -> str: + """Insert content after or before a specific line.""" + full_path = self._validate_path(path) + + if not full_path.exists(): + return f"Error: File '{path}' not found" + + warning = self._check_file_size(full_path) + + try: + with open(full_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except UnicodeDecodeError: + return f"Error: File '{path}' is not valid UTF-8 text" + + insert_content = content if content.endswith("\n") else content + "\n" + + if before is not None: + if before < 1: + return f"Error: before_line must be >= 1, got {before}" + if before > len(lines) + 1: + return f"Error: before_line ({before}) exceeds file length + 1 ({len(lines) + 1})" + insert_pos = before - 1 + position_desc = f"before line {before}" + else: + if after is None: + after = 0 + if after < 0: + return f"Error: after_line must be >= 0, got {after}" + if after > len(lines): + return f"Error: after_line ({after}) exceeds file length ({len(lines)})" + insert_pos = after + position_desc = f"after line {after}" if after > 0 else "at beginning" + + new_lines = lines[:insert_pos] + [insert_content] + lines[insert_pos:] + new_content = "".join(new_lines) + + self._atomic_write(full_path, new_content) + + result = f"Inserted content {position_desc} in '{path}'" + if warning: + result = f"{warning}\n{result}" + + # Validate syntax after edit + syntax_warning = self._validate_syntax(new_content, path) + if syntax_warning: + result = f"{result}{syntax_warning}" + + return result + + def _delete_lines(self, path: str, start: int, end: int) -> str: + """Delete lines start to end (1-indexed).""" + full_path = self._validate_path(path) + + if not full_path.exists(): + return f"Error: File '{path}' not found" + + warning = self._check_file_size(full_path) + + try: + with open(full_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except UnicodeDecodeError: + return f"Error: File '{path}' is not valid UTF-8 text" + + self._validate_line_bounds(lines, start, end, path) + + new_lines = lines[: start - 1] + lines[end:] + new_content = "".join(new_lines) + + self._atomic_write(full_path, new_content) + + deleted_count = end - start + 1 + result = f"Deleted {deleted_count} line(s) ({start}-{end}) from '{path}'" + if warning: + result = f"{warning}\n{result}" + + # Validate syntax after edit + syntax_warning = self._validate_syntax(new_content, path) + if syntax_warning: + result = f"{result}{syntax_warning}" + + return result + + def _search_replace(self, path: str, old_text: str, new_text: str) -> str: + """Find and replace exact text in file. + + More robust than line-based editing because it doesn't require line numbers. + Fails if old_text is not found or found multiple times. + + Args: + path: Relative path to the file + old_text: Exact text to find (must be unique in file) + new_text: Text to replace with + + Returns: + Success message or error with helpful context + """ + full_path = self._validate_path(path) + + if not full_path.exists(): + return f"Error: File '{path}' not found. Use find_files to locate the file." + + warning = self._check_file_size(full_path) + + try: + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + except UnicodeDecodeError: + return f"Error: File '{path}' is not valid UTF-8 text" + + # Count exact matches + count = content.count(old_text) + + if count == 0: + return self._format_search_error(content, old_text, path) + + if count > 1: + return ( + f"Error: Found {count} occurrences of the search text in '{path}'. " + f"Make the search text more specific by including more context lines.\n" + f"Tip: Use read_file_fragment to see the file and identify unique context." + ) + + # Perform replacement + new_content = content.replace(old_text, new_text, 1) + + self._atomic_write(full_path, new_content) + + # Find line numbers for reporting + char_pos = content.index(old_text) + lines_before = content[:char_pos].count("\n") + 1 + lines_in_old = old_text.count("\n") + end_line = lines_before + lines_in_old + + result = f"Replaced text at lines {lines_before}-{end_line} in '{path}'" + if warning: + result = f"{warning}\n{result}" + + # Validate syntax after edit + syntax_warning = self._validate_syntax(new_content, path) + if syntax_warning: + result = f"{result}{syntax_warning}" + + return result + + def _format_search_error(self, content: str, old_text: str, path: str) -> str: + """Format helpful error message when search text not found.""" + # Try to find similar text + old_lines = old_text.strip().split("\n") + if old_lines: + first_line = old_lines[0].strip() + if len(first_line) > 10: # Only search if first line is meaningful + for i, line in enumerate(content.split("\n"), 1): + if first_line in line: + return ( + f"Error: Search text not found exactly in '{path}'.\n" + f"Found similar text at line {i}:\n" + f" {line.strip()[:60]}{'...' if len(line.strip()) > 60 else ''}\n" + f"Tip: Use read_file_fragment('{path}:{max(1, i - 2)}:{i + 2}') " + f"to see the exact content." + ) + + return ( + f"Error: Search text not found in '{path}'.\n" + f"Tip: Use read_file_fragment to view the file content first, " + f"then copy the exact text to replace." + ) + + def _validate_path(self, path: str) -> Path: + """Validate and resolve path within root_dir.""" + full_path = (self.root_dir / path).resolve() + + if not str(full_path).startswith(str(self.root_dir.resolve())): + raise ValueError(f"Path '{path}' is outside of project root") + + if self._is_ignored(full_path): + raise ValueError(f"Path '{path}' is in an ignored directory") + + if full_path.suffix.lower() in self.BINARY_EXTENSIONS: + raise ValueError(f"Cannot edit binary file: {path}") + + return full_path + + def _validate_line_bounds(self, lines: List[str], start: int, end: int, path: str = "file") -> None: + """Validate line numbers are within bounds with helpful error messages.""" + total_lines = len(lines) + + if start < 1: + raise ValueError( + f"Start line must be >= 1, got {start}. Line numbers are 1-indexed. " + f"Use read_file_fragment to verify line numbers." + ) + if end < start: + raise ValueError( + f"End line ({end}) must be >= start line ({start}). " + f"Use read_file_fragment('{path}:{start}:{start + 5}') to see the content." + ) + if start > total_lines: + raise ValueError( + f"Start line ({start}) exceeds file length ({total_lines} lines). " + f"Use read_file_fragment('{path}:{max(1, total_lines - 5)}:{total_lines}') " + f"to see the end of the file." + ) + if end > total_lines + 1: + raise ValueError( + f"End line ({end}) exceeds file length + 1 ({total_lines + 1}). " + f"Use read_file_fragment to verify line numbers before editing." + ) + + def _check_file_size(self, path: Path) -> Optional[str]: + """Check file size and return warning if large.""" + size = path.stat().st_size + if size > self.MAX_FILE_SIZE: + raise ValueError(f"File too large ({size} bytes). Maximum is {self.MAX_FILE_SIZE}") + if size > self.WARN_FILE_SIZE: + return f"Warning: Large file ({size} bytes). Proceeding with edit." + return None + + def _atomic_write(self, path: Path, content: str) -> None: + """Write content atomically using temp file + rename.""" + import os + import tempfile + + dir_path = path.parent + fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".tmp_edit_") + + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, path) + except Exception: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + + def _validate_syntax(self, content: str, path: str) -> Optional[str]: + """Validate syntax of the content after an edit. + + Args: + content: The file content after the edit + path: The file path (used for language detection) + + Returns: + Warning message if there are syntax errors, None otherwise + """ + from agentic_framework.tools.syntax_validator import get_validator + + result = get_validator().validate(content, path) + return result.warning_message diff --git a/agentic-framework/src/agentic_framework/tools/example.py b/agentic-framework/src/agentic_framework/tools/example.py index 7b9cd2b..a6cef53 100644 --- a/agentic-framework/src/agentic_framework/tools/example.py +++ b/agentic-framework/src/agentic_framework/tools/example.py @@ -26,7 +26,38 @@ class CalculatorTool(Tool): - """A simple calculator tool with safe math evaluation.""" + """ + A calculator tool that provides safe mathematical expression evaluation. + + This tool uses Abstract Syntax Tree (AST) parsing to evaluate mathematical + expressions safely, preventing code injection by only allowing specific + operators and functions. + + Features: + - Supports basic arithmetic operations: +, -, *, /, //, %, ** + - Supports unary operators: +, - (negation) + - Built-in functions: abs, round, min, max, sum + - Supports lists and tuples for multi-argument functions + - Safe evaluation using AST parsing + + Usage Examples: + "2 + 2 * 3" # Returns "8" + "(5 + 3) * 2" # Returns "16" + "abs(-5)" # Returns "5" + "min([1, 2, 3])" # Returns "1" + "max(4, 7, 2)" # Returns "7" + "sum([1, 2, 3, 4])" # Returns "10" + + Error Handling: + - Invalid syntax raises ValueError with descriptive message + - Unsupported operators/functions raise ValueError + - Malformed expressions raise ValueError + + Security: + - All expressions are parsed through AST before evaluation + - Only pre-approved operators and functions are allowed + - No arbitrary code execution possible + """ @property def name(self) -> str: @@ -89,7 +120,7 @@ def _eval_node(self, node: ast.AST) -> Any: class WeatherTool(Tool): - """A mock weather tool.""" + """A mock weather tool for testing.""" @property def name(self) -> str: diff --git a/agentic-framework/src/agentic_framework/tools/syntax_validator.py b/agentic-framework/src/agentic_framework/tools/syntax_validator.py new file mode 100644 index 0000000..a18240b --- /dev/null +++ b/agentic-framework/src/agentic_framework/tools/syntax_validator.py @@ -0,0 +1,244 @@ +"""Syntax validation module using Tree-sitter for multi-language support.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from tree_sitter import Parser + +# Language detection by file extension +LANGUAGE_EXTENSIONS: Dict[str, str] = { + ".py": "python", + ".pyi": "python", + ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".hpp": "cpp", + ".php": "php", +} + +# Map our language names to tree-sitter-languages names +TREE_SITTER_LANGUAGE_MAP: Dict[str, str] = { + "python": "python", + "javascript": "javascript", + "typescript": "typescript", + "rust": "rust", + "go": "go", + "java": "java", + "c": "c", + "cpp": "cpp", + "php": "php", +} + +# Maximum file size to validate (for performance) +MAX_FILE_SIZE = 500 * 1024 # 500KB + + +@dataclass +class ValidationError: + """Represents a single syntax error.""" + + line: int + column: int + message: str + + def __str__(self) -> str: + return f"Line {self.line}, Col {self.column}: {self.message}" + + +@dataclass +class ValidationResult: + """Result of syntax validation.""" + + is_valid: bool + language: Optional[str] + errors: list[ValidationError] + skipped: bool = False + skip_reason: Optional[str] = None + + @property + def warning_message(self) -> Optional[str]: + """Generate a warning message if there are errors.""" + if self.is_valid or self.skipped: + return None + + error_count = len(self.errors) + lang_str = self.language or "unknown" + + lines = ["\n\nSYNTAX WARNING:", f"Found {error_count} syntax error(s) in {lang_str} code:"] + + for error in self.errors[:5]: # Limit to first 5 errors + lines.append(f" {error}") + + if error_count > 5: + lines.append(f" ... and {error_count - 5} more error(s)") + + return "\n".join(lines) + + +class SyntaxValidator: + """Validates syntax of code files using Tree-sitter.""" + + def __init__(self) -> None: + self._parsers: Dict[str, "Parser"] = {} + self._available = self._check_availability() + + def _check_availability(self) -> bool: + """Check if tree-sitter is available.""" + import importlib.util + + return importlib.util.find_spec("tree_sitter_languages") is not None + + def _get_parser(self, language: str) -> Optional["Parser"]: + """Get or create a parser for the given language.""" + if not self._available: + return None + + ts_lang = TREE_SITTER_LANGUAGE_MAP.get(language) + if not ts_lang: + return None + + if ts_lang not in self._parsers: + try: + import tree_sitter_languages # type: ignore[import-untyped] + + parser = tree_sitter_languages.get_parser(ts_lang) + self._parsers[ts_lang] = parser + except Exception: + return None + + return self._parsers[ts_lang] + + def validate(self, content: str, file_path: str) -> ValidationResult: + """Validate the syntax of the given content. + + Args: + content: The source code content to validate + file_path: Path to the file (used for language detection) + + Returns: + ValidationResult with validity status and any errors + """ + # Detect language from file extension + ext = Path(file_path).suffix.lower() + language = LANGUAGE_EXTENSIONS.get(ext) + + if language is None: + return ValidationResult( + is_valid=True, + language=None, + errors=[], + skipped=True, + skip_reason=f"Unsupported file extension: {ext}", + ) + + # Check file size + content_size = len(content.encode("utf-8")) + if content_size > MAX_FILE_SIZE: + return ValidationResult( + is_valid=True, + language=language, + errors=[], + skipped=True, + skip_reason=f"File too large ({content_size} bytes)", + ) + + # Check if tree-sitter is available + if not self._available: + return ValidationResult( + is_valid=True, + language=language, + errors=[], + skipped=True, + skip_reason="tree-sitter-languages not installed", + ) + + # Get parser for language + parser = self._get_parser(language) + if parser is None: + return ValidationResult( + is_valid=True, + language=language, + errors=[], + skipped=True, + skip_reason=f"No parser available for {language}", + ) + + # Parse the content + try: + tree = parser.parse(content.encode("utf-8")) + except Exception as e: + return ValidationResult( + is_valid=True, + language=language, + errors=[], + skipped=True, + skip_reason=f"Parse error: {e}", + ) + + # Find errors in the tree + errors = self._find_errors(tree.root_node) + + return ValidationResult( + is_valid=len(errors) == 0, + language=language, + errors=errors, + skipped=False, + skip_reason=None, + ) + + def _find_errors(self, node: Any) -> list[ValidationError]: + """Recursively find ERROR nodes in the AST.""" + errors: list[ValidationError] = [] + + if hasattr(node, "type") and node.type == "ERROR": + # Get the location of the error + start_point = node.start_point + errors.append( + ValidationError( + line=start_point[0] + 1, # 0-indexed to 1-indexed + column=start_point[1] + 1, + message=f"Syntax error near '{self._get_error_context(node)}'", + ) + ) + + # Recursively check children + if hasattr(node, "children"): + for child in node.children: + errors.extend(self._find_errors(child)) + + return errors + + def _get_error_context(self, node: Any) -> str: + """Get a short context string for an error node.""" + try: + if hasattr(node, "text"): + text = node.text.decode("utf-8") if isinstance(node.text, bytes) else str(node.text) + # Limit to 30 chars for readability + if len(text) > 30: + return text[:27] + "..." + return text or "" + except Exception: + pass + return "" + + +# Singleton instance +_validator: Optional[SyntaxValidator] = None + + +def get_validator() -> SyntaxValidator: + """Get the singleton SyntaxValidator instance.""" + global _validator + if _validator is None: + _validator = SyntaxValidator() + return _validator diff --git a/agentic-framework/tests/test_codebase_explorer.py b/agentic-framework/tests/test_codebase_explorer.py index 4a9a6b8..2155041 100644 --- a/agentic-framework/tests/test_codebase_explorer.py +++ b/agentic-framework/tests/test_codebase_explorer.py @@ -8,6 +8,7 @@ from agentic_framework.tools.codebase_explorer import ( LANGUAGE_EXTENSIONS, LANGUAGE_PATTERNS, + FileEditorTool, FileFragmentReaderTool, FileOutlinerTool, StructureExplorerTool, @@ -578,3 +579,354 @@ def test_all_patterns_compile(self): # Should not raise exception compiled = re.compile(pattern) assert compiled is not None + + +class TestFileEditorTool: + """Tests for FileEditorTool.""" + + @pytest.fixture + def temp_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def editor(self, temp_dir): + return FileEditorTool(str(temp_dir)) + + # Replace tests + def test_replace_lines(self, editor, temp_dir): + """Test replacing a range of lines.""" + (temp_dir / "test.py").write_text("line1\nline2\nline3\nline4\n") + result = editor.invoke("replace:test.py:2:3:new_line2\nnew_line3") + assert "Replaced lines 2-3" in result + content = (temp_dir / "test.py").read_text() + assert "line1" in content + assert "new_line2" in content + assert "new_line3" in content + assert "line4" in content + + def test_replace_single_line(self, editor, temp_dir): + """Test replacing a single line.""" + (temp_dir / "test.py").write_text("line1\nline2\nline3\n") + result = editor.invoke("replace:test.py:2:2:replaced") + assert "Replaced lines 2-2" in result + content = (temp_dir / "test.py").read_text() + assert content == "line1\nreplaced\nline3\n" + + def test_replace_all_lines(self, editor, temp_dir): + """Test replacing all lines in file.""" + (temp_dir / "test.py").write_text("old1\nold2\nold3\n") + result = editor.invoke("replace:test.py:1:3:new_content") + assert "Replaced lines 1-3" in result + content = (temp_dir / "test.py").read_text() + assert content == "new_content\n" + + # Insert tests + def test_insert_after_line(self, editor, temp_dir): + """Test inserting after a specific line.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("insert:test.py:1:inserted") + assert "Inserted content after line 1" in result + content = (temp_dir / "test.py").read_text() + lines = content.split("\n") + assert lines[0] == "line1" + assert lines[1] == "inserted" + assert lines[2] == "line2" + + def test_insert_before_line(self, editor, temp_dir): + """Test inserting before a specific line.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("insert:test.py:before_1:inserted") + assert "Inserted content before line 1" in result + content = (temp_dir / "test.py").read_text() + lines = content.split("\n") + assert lines[0] == "inserted" + assert lines[1] == "line1" + + def test_insert_at_beginning(self, editor, temp_dir): + """Test inserting at the beginning of file.""" + (temp_dir / "test.py").write_text("line1\n") + result = editor.invoke("insert:test.py:0:first_line") + assert "at beginning" in result + content = (temp_dir / "test.py").read_text() + assert content.startswith("first_line\n") + + def test_insert_after_last_line(self, editor, temp_dir): + """Test inserting after the last line.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("insert:test.py:2:appended") + assert "after line 2" in result + content = (temp_dir / "test.py").read_text() + assert content == "line1\nline2\nappended\n" + + # Delete tests + def test_delete_lines(self, editor, temp_dir): + """Test deleting a range of lines.""" + (temp_dir / "test.py").write_text("line1\nline2\nline3\nline4\n") + result = editor.invoke("delete:test.py:2:3") + assert "Deleted 2 line(s)" in result + assert "(2-3)" in result + content = (temp_dir / "test.py").read_text() + assert content == "line1\nline4\n" + + def test_delete_single_line(self, editor, temp_dir): + """Test deleting a single line.""" + (temp_dir / "test.py").write_text("line1\nline2\nline3\n") + result = editor.invoke("delete:test.py:2:2") + assert "Deleted 1 line(s)" in result + content = (temp_dir / "test.py").read_text() + assert content == "line1\nline3\n" + + def test_delete_all_lines(self, editor, temp_dir): + """Test deleting all lines from file.""" + (temp_dir / "test.py").write_text("line1\nline2\nline3\n") + result = editor.invoke("delete:test.py:1:3") + assert "Deleted 3 line(s)" in result + content = (temp_dir / "test.py").read_text() + assert content == "" + + # JSON format tests + def test_json_format_replace(self, editor, temp_dir): + """Test JSON format for complex content.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + json_input = '{"op": "replace", "path": "test.py", "start": 1, "end": 2, "content": "new\\ncontent"}' + result = editor.invoke(json_input) + assert "Replaced lines" in result + content = (temp_dir / "test.py").read_text() + assert "new" in content and "content" in content + + def test_json_format_insert(self, editor, temp_dir): + """Test JSON format for insert operation.""" + (temp_dir / "test.py").write_text("line1\n") + json_input = '{"op": "insert", "path": "test.py", "after": 0, "content": "first"}' + result = editor.invoke(json_input) + assert "Inserted content" in result + content = (temp_dir / "test.py").read_text() + assert content.startswith("first\n") + + def test_json_format_delete(self, editor, temp_dir): + """Test JSON format for delete operation.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + json_input = '{"op": "delete", "path": "test.py", "start": 1, "end": 1}' + result = editor.invoke(json_input) + assert "Deleted" in result + content = (temp_dir / "test.py").read_text() + assert content == "line2\n" + + # Validation tests + def test_path_traversal_prevention(self, editor, temp_dir): + """Test that path traversal is blocked.""" + result = editor.invoke("replace:../outside.txt:1:1:content") + assert "Error" in result + assert "outside" in result.lower() or "root" in result.lower() + + def test_line_bounds_checking(self, editor, temp_dir): + """Test that out-of-bounds lines are rejected.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("replace:test.py:1:100:content") + assert "Error" in result + assert "exceeds" in result.lower() or "bounds" in result.lower() + + def test_start_line_negative(self, editor, temp_dir): + """Test that negative start line is rejected.""" + # Create the file first + (temp_dir / "test.py").write_text("line1\n") + result = editor.invoke("replace:test.py:-1:1:content") + assert "Error" in result + assert ">=" in result or "must be >=" in result + + def test_end_line_before_start(self, editor, temp_dir): + """Test that end < start is rejected.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("replace:test.py:3:2:content") + assert "Error" in result + assert ">= start line" in result + + def test_binary_file_rejection(self, editor, temp_dir): + """Test that binary files are rejected.""" + (temp_dir / "test.png").write_bytes(b"\x89PNG\r\n\x1a\n") + result = editor.invoke("replace:test.png:1:1:content") + assert "Error" in result + assert "binary" in result.lower() + + def test_pyc_file_rejection(self, editor, temp_dir): + """Test that .pyc files are rejected.""" + (temp_dir / "test.pyc").write_bytes(b"compiled python") + result = editor.invoke("replace:test.pyc:1:1:content") + assert "Error" in result + assert "binary" in result.lower() + + def test_nonexistent_file(self, editor, temp_dir): + """Test error for non-existent file.""" + result = editor.invoke("replace:nonexistent.py:1:1:content") + assert "Error" in result + assert "not found" in result.lower() + + def test_invalid_format(self, editor): + """Test error for invalid input format.""" + result = editor.invoke("invalid input") + assert "Error" in result + + def test_unknown_operation(self, editor): + """Test error for unknown operation.""" + result = editor.invoke("unknown:test.py:1:1:content") + assert "Error" in result + assert "unknown" in result.lower() + + def test_invalid_replace_format(self, editor): + """Test error for incomplete replace format.""" + result = editor.invoke("replace:test.py:1:2") + assert "Error" in result + assert "Replace format" in result + + def test_invalid_insert_format(self, editor): + """Test error for incomplete insert format.""" + result = editor.invoke("insert:test.py:1") + assert "Error" in result + assert "Insert format" in result + + def test_invalid_delete_format(self, editor): + """Test error for incomplete delete format.""" + result = editor.invoke("delete:test.py:1") + assert "Error" in result + assert "Delete format" in result + + def test_content_with_newline_escaped(self, editor, temp_dir): + """Test that \\n escape is properly handled in delimited format.""" + (temp_dir / "test.py").write_text("old\n") + result = editor.invoke("replace:test.py:1:1:first\\nsecond") + assert "Replaced lines" in result + content = (temp_dir / "test.py").read_text() + assert content == "first\nsecond\n" + + def test_empty_file_replace(self, editor, temp_dir): + """Test that replace on empty file is handled properly.""" + (temp_dir / "empty.py").write_text("") + result = editor.invoke("replace:empty.py:1:1:content") + assert "Error" in result + assert "exceeds" in result.lower() + + def test_empty_file_insert(self, editor, temp_dir): + """Test that insert at beginning works on empty file.""" + (temp_dir / "empty.py").write_text("") + result = editor.invoke("insert:empty.py:0:content") + assert "Inserted content" in result + content = (temp_dir / "empty.py").read_text() + assert content == "content\n" + + def test_insert_before_invalid(self, editor, temp_dir): + """Test insert before with invalid line number.""" + (temp_dir / "test.py").write_text("line1\n") + result = editor.invoke("insert:test.py:before_0:content") + assert "Error" in result + assert ">=" in result + + def test_insert_after_invalid(self, editor, temp_dir): + """Test insert after with line number too high.""" + (temp_dir / "test.py").write_text("line1\n") + result = editor.invoke("insert:test.py:10:content") + assert "Error" in result + assert "exceeds" in result.lower() + + def test_invalid_insert_position(self, editor, temp_dir): + """Test insert with invalid position format.""" + (temp_dir / "test.py").write_text("line1\n") + result = editor.invoke("insert:test.py:middle:content") + assert "Error" in result + assert "Invalid insert position" in result + + def test_edit_adds_newline_if_missing(self, editor, temp_dir): + """Test that newline is added if content doesn't end with one.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke("replace:test.py:2:2:newline") + assert "Replaced lines" in result + content = (temp_dir / "test.py").read_text() + # Should have newline after replacement + assert content == "line1\nnewline\n" + + def test_replace_preserves_existing_newlines(self, editor, temp_dir): + """Test that replace with content ending in newline works correctly.""" + (temp_dir / "test.py").write_text("old1\nold2\n") + result = editor.invoke("replace:test.py:1:1:new1\n") + assert "Replaced lines" in result + content = (temp_dir / "test.py").read_text() + # Should not have double newlines + assert content == "new1\nold2\n" + + # Search/Replace tests + def test_search_replace_exact_match(self, editor, temp_dir): + """Test search_replace with exact text match.""" + (temp_dir / "test.py").write_text('"""A mock weather tool."""\n') + json_input = ( + '{"op": "search_replace", "path": "test.py", ' + '"old": "A mock weather tool.", "new": "A mock weather tool for testing."}' + ) + result = editor.invoke(json_input) + assert "Replaced text" in result + content = (temp_dir / "test.py").read_text() + assert "for testing" in content + + def test_search_replace_multiline(self, editor, temp_dir): + """Test search_replace with multiline content.""" + (temp_dir / "test.py").write_text("def foo():\n pass\n\ndef bar():\n pass\n") + json_input = ( + '{"op": "search_replace", "path": "test.py", ' + '"old": "def foo():\\n pass", "new": "def foo():\\n return 42"}' + ) + result = editor.invoke(json_input) + assert "Replaced text" in result + content = (temp_dir / "test.py").read_text() + assert "return 42" in content + assert "def bar" in content # Unchanged + + def test_search_replace_not_found(self, editor, temp_dir): + """Test search_replace when text is not found.""" + (temp_dir / "test.py").write_text("line1\nline2\n") + result = editor.invoke( + '{"op": "search_replace", "path": "test.py", "old": "nonexistent", "new": "replacement"}' + ) + assert "Error" in result + assert "not found" in result.lower() + + def test_search_replace_multiple_matches(self, editor, temp_dir): + """Test search_replace when text appears multiple times.""" + (temp_dir / "test.py").write_text("foo\nbar\nfoo\n") + result = editor.invoke('{"op": "search_replace", "path": "test.py", "old": "foo", "new": "baz"}') + assert "Error" in result + assert "2 occurrences" in result.lower() + + def test_search_replace_similar_text_hint(self, editor, temp_dir): + """Test search_replace suggests location when text not found but similar exists.""" + (temp_dir / "test.py").write_text("class WeatherTool:\n pass\n") + result = editor.invoke('{"op": "search_replace", "path": "test.py", "old": "WeatherTools:", "new": "SkyTool:"}') + # Should find similar text and suggest location (note: "WeatherTools" vs "WeatherTool") + assert "Error" in result + # Should suggest read_file_fragment + assert "read_file_fragment" in result + + def test_search_replace_preserves_quotes(self, editor, temp_dir): + """Test that search_replace preserves surrounding content.""" + (temp_dir / "test.py").write_text(' """A mock weather tool."""\n') + json_input = ( + '{"op": "search_replace", "path": "test.py", ' + '"old": "A mock weather tool.", "new": "A mock weather tool for testing."}' + ) + result = editor.invoke(json_input) + assert "Replaced text" in result + content = (temp_dir / "test.py").read_text() + # Quotes should be preserved + assert '"""' in content + assert "for testing" in content + + def test_search_replace_file_not_found(self, editor, temp_dir): + """Test search_replace on non-existent file.""" + result = editor.invoke('{"op": "search_replace", "path": "nonexistent.py", "old": "old", "new": "new"}') + assert "Error" in result + assert "not found" in result.lower() + + def test_search_replace_empty_old_text(self, editor, temp_dir): + """Test search_replace with empty old text.""" + (temp_dir / "test.py").write_text("content\n") + result = editor.invoke('{"op": "search_replace", "path": "test.py", "old": "", "new": "new"}') + # Empty string matches everywhere, should fail + assert "Error" in result or "0 occurrences" in result.lower() or "not found" in result.lower() diff --git a/agentic-framework/tests/test_developer_agent.py b/agentic-framework/tests/test_developer_agent.py index 505bd18..104b3e9 100644 --- a/agentic-framework/tests/test_developer_agent.py +++ b/agentic-framework/tests/test_developer_agent.py @@ -21,6 +21,7 @@ def test_developer_agent_system_prompt(monkeypatch): assert "get_file_outline" in agent.system_prompt assert "read_file_fragment" in agent.system_prompt assert "code_search" in agent.system_prompt + assert "edit_file" in agent.system_prompt def test_developer_agent_local_tools_count(monkeypatch): @@ -30,10 +31,17 @@ def test_developer_agent_local_tools_count(monkeypatch): agent = DeveloperAgent(initial_mcp_tools=[]) tools = agent.get_tools() - assert len(tools) == 5 + assert len(tools) == 6 tool_names = {tool.name for tool in tools} - expected_names = {"discover_structure", "find_files", "get_file_outline", "read_file_fragment", "code_search"} + expected_names = { + "discover_structure", + "find_files", + "get_file_outline", + "read_file_fragment", + "code_search", + "edit_file", + } assert tool_names == expected_names @@ -52,6 +60,7 @@ def test_developer_agent_tool_descriptions(monkeypatch): assert "class" in outline_desc and "function" in outline_desc assert "specific line range" in tools_by_name["read_file_fragment"].description.lower() assert "ripgrep" in tools_by_name["code_search"].description.lower() + assert "line-based operations" in tools_by_name["edit_file"].description.lower() def test_developer_agent_run(monkeypatch): @@ -85,7 +94,7 @@ class MockMCPTool: asyncio.run(agent.run("test")) tools = agent.get_tools() - # Should have 5 local tools + 1 MCP tool - assert len(tools) == 6 + # Should have 6 local tools + 1 MCP tool + assert len(tools) == 7 tool_names = {tool.name for tool in tools} assert "webfetch" in tool_names diff --git a/agentic-framework/tests/test_syntax_validator.py b/agentic-framework/tests/test_syntax_validator.py new file mode 100644 index 0000000..73d9448 --- /dev/null +++ b/agentic-framework/tests/test_syntax_validator.py @@ -0,0 +1,375 @@ +"""Tests for syntax_validator module.""" + +from pathlib import Path + +import pytest + +from agentic_framework.tools.syntax_validator import ( + MAX_FILE_SIZE, + SyntaxValidator, + ValidationError, + ValidationResult, + get_validator, +) + + +class TestValidationError: + """Tests for ValidationError dataclass.""" + + def test_str_representation(self) -> None: + """Test string representation of ValidationError.""" + error = ValidationError(line=10, column=5, message="Unexpected token") + assert str(error) == "Line 10, Col 5: Unexpected token" + + def test_line_column_values(self) -> None: + """Test that line and column are stored correctly.""" + error = ValidationError(line=1, column=1, message="Error at start") + assert error.line == 1 + assert error.column == 1 + assert error.message == "Error at start" + + +class TestValidationResult: + """Tests for ValidationResult dataclass.""" + + def test_valid_result_no_warning(self) -> None: + """Test that valid result has no warning message.""" + result = ValidationResult(is_valid=True, language="python", errors=[]) + assert result.warning_message is None + + def test_invalid_result_has_warning(self) -> None: + """Test that invalid result has warning message.""" + errors = [ValidationError(line=5, column=1, message="Syntax error")] + result = ValidationResult(is_valid=False, language="python", errors=errors) + assert result.warning_message is not None + assert "SYNTAX WARNING" in result.warning_message + assert "python" in result.warning_message + assert "1 syntax error" in result.warning_message + + def test_multiple_errors_limited_in_output(self) -> None: + """Test that only first 5 errors are shown in warning.""" + errors = [ValidationError(line=i, column=1, message=f"Error {i}") for i in range(1, 11)] + result = ValidationResult(is_valid=False, language="python", errors=errors) + assert result.warning_message is not None + assert "10 syntax error" in result.warning_message + assert "5 more error" in result.warning_message + + def test_skipped_result_no_warning(self) -> None: + """Test that skipped result has no warning message.""" + result = ValidationResult(is_valid=True, language="python", errors=[], skipped=True, skip_reason="No parser") + assert result.warning_message is None + + +class TestSyntaxValidator: + """Tests for SyntaxValidator class.""" + + @pytest.fixture + def validator(self) -> SyntaxValidator: + """Create a fresh validator instance for each test.""" + return SyntaxValidator() + + def test_unsupported_extension_skipped(self, validator: SyntaxValidator) -> None: + """Test that unsupported file extensions are skipped.""" + result = validator.validate("some content", "file.txt") + assert result.skipped is True + assert "Unsupported file extension" in (result.skip_reason or "") + + def test_supported_languages(self, validator: SyntaxValidator) -> None: + """Test that supported language extensions are recognized.""" + supported = [".py", ".js", ".ts", ".rs", ".go", ".java", ".c", ".cpp", ".php"] + for ext in supported: + result = validator.validate("x = 1", f"file{ext}") + assert result.language is not None + # Skip reason can be various things like "tree-sitter not installed", "No parser available", etc. + # The key thing is that the language is detected correctly + if result.skipped: + assert result.skip_reason is not None + + def test_valid_python_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid Python code.""" + code = """ +def hello(): + print("Hello, world!") + +class MyClass: + pass +""" + result = validator.validate(code, "test.py") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_invalid_python_code(self, validator: SyntaxValidator) -> None: + """Test validation of invalid Python code.""" + code = """ +def hello(: + print("Missing closing paren" +""" + result = validator.validate(code, "test.py") + if not result.skipped: + assert result.is_valid is False + assert len(result.errors) > 0 + + def test_valid_javascript_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid JavaScript code.""" + code = """ +function hello() { + console.log("Hello, world!"); +} + +class MyClass { + constructor() { + this.value = 1; + } +} +""" + result = validator.validate(code, "test.js") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_invalid_javascript_code(self, validator: SyntaxValidator) -> None: + """Test validation of invalid JavaScript code.""" + code = """ +function hello( { + console.log("Missing closing paren"); +} +""" + result = validator.validate(code, "test.js") + if not result.skipped: + assert result.is_valid is False + assert len(result.errors) > 0 + + def test_valid_typescript_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid TypeScript code.""" + code = """ +interface User { + name: string; + age: number; +} + +function greet(user: User): string { + return `Hello, ${user.name}!`; +} +""" + result = validator.validate(code, "test.ts") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_rust_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid Rust code.""" + code = """ +fn main() { + println!("Hello, world!"); +} + +struct Point { + x: i32, + y: i32, +} +""" + result = validator.validate(code, "test.rs") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_go_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid Go code.""" + code = """ +package main + +func main() { + println("Hello, world!") +} + +type Point struct { + X int + Y int +} +""" + result = validator.validate(code, "test.go") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_java_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid Java code.""" + code = """ +public class Main { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } +} +""" + result = validator.validate(code, "test.java") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_c_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid C code.""" + code = """ +#include + +int main() { + printf("Hello, world!\\n"); + return 0; +} +""" + result = validator.validate(code, "test.c") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_cpp_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid C++ code.""" + code = """ +#include + +class Point { +public: + int x, y; + Point(int x, int y) : x(x), y(y) {} +}; + +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} +""" + result = validator.validate(code, "test.cpp") + if not result.skipped: + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_valid_php_code(self, validator: SyntaxValidator) -> None: + """Test validation of valid PHP code.""" + code = """ None: + """Test that large files are skipped for performance.""" + # Create content larger than MAX_FILE_SIZE + large_content = "x" * (MAX_FILE_SIZE + 1000) + result = validator.validate(large_content, "test.py") + assert result.skipped is True + assert "too large" in (result.skip_reason or "").lower() + + def test_empty_file_valid(self, validator: SyntaxValidator) -> None: + """Test that empty files are valid.""" + result = validator.validate("", "test.py") + if not result.skipped: + # Empty files are typically valid syntax + assert result.is_valid is True + + +class TestGetValidator: + """Tests for get_validator singleton function.""" + + def test_returns_same_instance(self) -> None: + """Test that get_validator returns the same instance.""" + v1 = get_validator() + v2 = get_validator() + assert v1 is v2 + + def test_returns_syntax_validator(self) -> None: + """Test that get_validator returns a SyntaxValidator.""" + validator = get_validator() + assert isinstance(validator, SyntaxValidator) + + +class TestFileEditorToolIntegration: + """Integration tests for FileEditorTool with syntax validation.""" + + @pytest.fixture + def temp_file(self, tmp_path: Path) -> Path: + """Create a temporary Python file for testing.""" + file_path = tmp_path / "test_file.py" + file_path.write_text( + """def hello(): + print("Hello") + +def world(): + print("World") +""" + ) + return file_path + + def test_edit_creates_invalid_syntax_warning(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that edit that creates invalid syntax shows warning.""" + from agentic_framework.tools.codebase_explorer import FileEditorTool + + # Create test file + test_file = tmp_path / "test.py" + test_file.write_text("def foo():\n pass\n") + + tool = FileEditorTool(root_dir=tmp_path) + + # Edit to create invalid syntax + result = tool.invoke(f'replace:{test_file.name}:1:2:def foo(:\\n print("broken")') + + # Should show syntax warning if tree-sitter is available + if "SYNTAX WARNING" in result: + assert "syntax error" in result.lower() + + def test_edit_preserves_valid_syntax(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that edit that preserves valid syntax shows no warning.""" + from agentic_framework.tools.codebase_explorer import FileEditorTool + + # Create test file + test_file = tmp_path / "test.py" + test_file.write_text("def foo():\n pass\n") + + tool = FileEditorTool(root_dir=tmp_path) + + # Edit with valid syntax + result = tool.invoke(f'replace:{test_file.name}:2:2: print("hello")') + + # Should not show syntax warning + assert "SYNTAX WARNING" not in result + assert "Replaced lines" in result + + def test_insert_with_valid_syntax(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that insert with valid syntax shows no warning.""" + from agentic_framework.tools.codebase_explorer import FileEditorTool + + # Create test file + test_file = tmp_path / "test.py" + test_file.write_text("def foo():\n pass\n") + + tool = FileEditorTool(root_dir=tmp_path) + + # Insert valid code + result = tool.invoke(f'insert:{test_file.name}:2: print("new line")') + + # Should not show syntax warning + assert "SYNTAX WARNING" not in result + assert "Inserted content" in result + + def test_delete_preserves_valid_syntax(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that delete that preserves valid syntax shows no warning.""" + from agentic_framework.tools.codebase_explorer import FileEditorTool + + # Create test file + test_file = tmp_path / "test.py" + test_file.write_text("def foo():\n pass\n\ndef bar():\n pass\n") + + tool = FileEditorTool(root_dir=tmp_path) + + # Delete a function (keeping valid syntax) + result = tool.invoke(f"delete:{test_file.name}:3:5") + + # Should not show syntax warning + assert "SYNTAX WARNING" not in result + assert "Deleted" in result diff --git a/agentic-framework/uv.lock b/agentic-framework/uv.lock index 5b452b1..531fd65 100644 --- a/agentic-framework/uv.lock +++ b/agentic-framework/uv.lock @@ -31,6 +31,8 @@ dependencies = [ { name = "rich" }, { name = "tavily" }, { name = "tavily-python" }, + { name = "tree-sitter" }, + { name = "tree-sitter-languages" }, { name = "typer" }, ] @@ -64,6 +66,8 @@ requires-dist = [ { name = "rich", specifier = ">=13.0.0" }, { name = "tavily", specifier = ">=1.1.0" }, { name = "tavily-python", specifier = ">=0.7.13" }, + { name = "tree-sitter", specifier = "==0.21.3" }, + { name = "tree-sitter-languages", specifier = ">=1.10.2" }, { name = "typer", specifier = ">=0.12.0" }, ] @@ -2741,6 +2745,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.21.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/9e/b7cb190aa08e4ea387f2b1531da03efb4b8b033426753c0b97e3698645f6/tree-sitter-0.21.3.tar.gz", hash = "sha256:b5de3028921522365aa864d95b3c41926e0ba6a85ee5bd000e10dc49b0766988", size = 155688, upload-time = "2024-03-26T10:53:35.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e1/cceb06eae617a6bf5eeeefa9813d9fd57d89b50f526ce02486a336bcd2a9/tree_sitter-0.21.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:669b3e5a52cb1e37d60c7b16cc2221c76520445bb4f12dd17fd7220217f5abf3", size = 133640, upload-time = "2024-03-26T10:52:59.135Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ce/ac14e5cbb0f30b7bd338122491ee2b8e6c0408cfe26741cbd66fa9b53d35/tree_sitter-0.21.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2aa2a5099a9f667730ff26d57533cc893d766667f4d8a9877e76a9e74f48f0d3", size = 125954, upload-time = "2024-03-26T10:53:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/76dbf830126e566c48db0d1bf2bef3f9d8cac938302a9b0f762ded8206c2/tree_sitter-0.21.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3e06ae2a517cf6f1abb682974f76fa760298e6d5a3ecf2cf140c70f898adf0", size = 490092, upload-time = "2024-03-26T10:53:03.144Z" }, + { url = "https://files.pythonhosted.org/packages/ec/87/0c3593552cb0d09ab6271d37fc0e6a9476919d2a975661d709d4b3289fc7/tree_sitter-0.21.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af992dfe08b4fefcfcdb40548d0d26d5d2e0a0f2d833487372f3728cd0772b48", size = 502155, upload-time = "2024-03-26T10:53:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/05/92/b2cb22cf52c18fcc95662897f380cf230c443dfc9196b872aad5948b7bb3/tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c7cbab1dd9765138505c4a55e2aa857575bac4f1f8a8b0457744a4fefa1288e6", size = 486020, upload-time = "2024-03-26T10:53:06.414Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/69b543538a46d763f3e787234d1617b718ab90f32ffa676ca856f1d9540e/tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1e66aeb457d1529370fcb0997ae5584c6879e0e662f1b11b2f295ea57e22f54", size = 496348, upload-time = "2024-03-26T10:53:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4f/df4ea84476443021707b537217c32147ccccbc3e10c17b216a969991e1b3/tree_sitter-0.21.3-cp312-cp312-win_amd64.whl", hash = "sha256:013c750252dc3bd0e069d82e9658de35ed50eecf31c6586d0de7f942546824c5", size = 109771, upload-time = "2024-03-26T10:53:10.342Z" }, +] + +[[package]] +name = "tree-sitter-languages" +version = "1.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tree-sitter" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/bf/a9bd2d6ecbd053de0a5a50c150105b69c90eb49089f9e1d4fc4937e86adc/tree_sitter_languages-1.10.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c0f4c8b2734c45859edc7fcaaeaab97a074114111b5ba51ab4ec7ed52104763c", size = 8884771, upload-time = "2024-02-04T10:28:39.655Z" }, + { url = "https://files.pythonhosted.org/packages/14/fb/1f6fe5903aeb7435cc66d4b56621e9a30a4de64420555b999de65b31fcae/tree_sitter_languages-1.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eecd3c1244ac3425b7a82ba9125b4ddb45d953bbe61de114c0334fd89b7fe782", size = 9724562, upload-time = "2024-02-04T10:28:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/6c/1855a65c9d6b50600f7a68e0182153db7cb12ff81fdebd93e87851dfdd8f/tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15db3c8510bc39a80147ee7421bf4782c15c09581c1dc2237ea89cefbd95b846", size = 8678682, upload-time = "2024-02-04T10:28:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/d0/75/eff180f187ce4dc3e5177b3f8508e0061ea786ac44f409cf69cf24bf31a6/tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92c6487a6feea683154d3e06e6db68c30e0ae749a7ce4ce90b9e4e46b78c85c7", size = 8595099, upload-time = "2024-02-04T10:28:47.767Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e6/eddc76ad899d77adcb5fca6cdf651eb1d33b4a799456bf303540f6cf8204/tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2f1cd1d1bdd65332f9c2b67d49dcf148cf1ded752851d159ac3e5ee4f4d260", size = 8433569, upload-time = "2024-02-04T10:28:50.404Z" }, + { url = "https://files.pythonhosted.org/packages/06/95/a13da048c33a876d0475974484bf66b1fae07226e8654b1365ab549309cd/tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:976c8039165b8e12f17a01ddee9f4e23ec6e352b165ad29b44d2bf04e2fbe77e", size = 9196003, upload-time = "2024-02-04T10:28:52.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/13/9e5cb03914d60dd51047ecbfab5400309fbab14bb25014af388f492da044/tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:dafbbdf16bf668a580902e1620f4baa1913e79438abcce721a50647564c687b9", size = 9175560, upload-time = "2024-02-04T10:28:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/19/76/25bb32a9be1c476e388835d5c8de5af2920af055e295770003683896cfe2/tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1aeabd3d60d6d276b73cd8f3739d595b1299d123cc079a317f1a5b3c5461e2ca", size = 8956249, upload-time = "2024-02-04T10:28:57.094Z" }, + { url = "https://files.pythonhosted.org/packages/52/01/8e2f97a444d25dde1380ec20b338722f733b6cc290524357b1be3dd452ab/tree_sitter_languages-1.10.2-cp312-cp312-win32.whl", hash = "sha256:fab8ee641914098e8933b87ea3d657bea4dd00723c1ee7038b847b12eeeef4f5", size = 8363094, upload-time = "2024-02-04T10:28:59.156Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/0262e875dd899447476a8ffde7829df3716ffa772990095c65d6de1f053c/tree_sitter_languages-1.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:5e606430d736367e5787fa5a7a0c5a1ec9b85eded0b3596bbc0d83532a40810b", size = 8268983, upload-time = "2024-02-04T10:29:00.987Z" }, +] + [[package]] name = "truststore" version = "0.10.4"