From aa0d08be0ef74781050f5064e6e334be83f045f3 Mon Sep 17 00:00:00 2001 From: Jean Silva Date: Sat, 21 Feb 2026 16:42:57 +0100 Subject: [PATCH 1/4] chore(deps): add tree-sitter and tree-sitter-languages to lockfile Add tree-sitter (0.21.3) and tree-sitter-languages (>=1.10.2) entries to the agentic-framework/uv.lock file. This updates the packages list and includes their sdist and wheel metadata so package resolution and reproducible installs can pick up prebuilt wheels across platforms. The change ensures the project can depend on Tree-sitter parsers and the language bundles, resolving runtime parsing needs and preventing missing dependency issues during installs and CI. --- agentic-framework/pyproject.toml | 2 + .../agentic_framework/core/developer_agent.py | 84 +++- .../src/agentic_framework/tools/__init__.py | 6 + .../tools/codebase_explorer.py | 422 ++++++++++++++++++ .../src/agentic_framework/tools/example.py | 2 +- .../tools/syntax_validator.py | 244 ++++++++++ .../tests/test_codebase_explorer.py | 352 +++++++++++++++ .../tests/test_developer_agent.py | 17 +- .../tests/test_syntax_validator.py | 375 ++++++++++++++++ agentic-framework/uv.lock | 39 ++ 10 files changed, 1521 insertions(+), 22 deletions(-) create mode 100644 agentic-framework/src/agentic_framework/tools/syntax_validator.py create mode 100644 agentic-framework/tests/test_syntax_validator.py 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..9914289 100644 --- a/agentic-framework/src/agentic_framework/tools/example.py +++ b/agentic-framework/src/agentic_framework/tools/example.py @@ -89,7 +89,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" From 9c1bd5f5cfc0f9759c34b26943d15f4aea25f7b2 Mon Sep 17 00:00:00 2001 From: Jean Silva Date: Sat, 21 Feb 2026 18:53:09 +0100 Subject: [PATCH 2/4] ci: limit CI matrix to Python 3.12 only Reduce the GitHub Actions CI matrix from testing Python 3.12 and 3.13 to testing only 3.12. This simplifies CI runs, reduces execution time and resource usage, and avoids running against a Python version that is not currently required or supported by the project. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a05a5be42b666b368ccf720dc487d7f9306b0765 Mon Sep 17 00:00:00 2001 From: Jean Silva Date: Sat, 21 Feb 2026 20:21:55 +0100 Subject: [PATCH 3/4] feat: use "developer" agentic to add automatic docs Update calculator tool documentation by running the recently corrected edit tool with: ``` uv --directory agentic-framework run agentic-run developer -i "Add CalculatorTool class documentation in example.py" ``` --- .../src/agentic_framework/tools/example.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/agentic-framework/src/agentic_framework/tools/example.py b/agentic-framework/src/agentic_framework/tools/example.py index 9914289..8d3a607 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: From 0a20a8ca7bb651273b40c1031f8797f59d781d7e Mon Sep 17 00:00:00 2001 From: Jean Silva Date: Sun, 22 Feb 2026 00:33:03 +0100 Subject: [PATCH 4/4] docs: tidy docstring formatting in CalculatorTool Clean up spacing and blank lines in the CalculatorTool class docstring to improve readability and consistency. Remove superfluous trailing spaces and normalize blank lines between sections (Features, Usage Examples, Error Handling, Security) so the documentation is cleaner and easier to maintain. --- .../src/agentic_framework/tools/example.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agentic-framework/src/agentic_framework/tools/example.py b/agentic-framework/src/agentic_framework/tools/example.py index 8d3a607..a6cef53 100644 --- a/agentic-framework/src/agentic_framework/tools/example.py +++ b/agentic-framework/src/agentic_framework/tools/example.py @@ -28,18 +28,18 @@ class CalculatorTool(Tool): """ 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" @@ -47,12 +47,12 @@ class CalculatorTool(Tool): "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