bone-agent 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,231 @@
1
+ """Tool auto-discovery and loading mechanism.
2
+
3
+ This module provides automatic discovery and loading of tools from
4
+ multiple directories. Tools are imported to trigger @tool decorator
5
+ registration.
6
+ """
7
+
8
+ import importlib.util
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from .base import ToolRegistry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _is_python_file(path: Path) -> bool:
20
+ """Check if a file is a Python module.
21
+
22
+ Args:
23
+ path: File path to check
24
+
25
+ Returns:
26
+ True if file is a .py file (not __pycache__ or test file)
27
+ """
28
+ return (
29
+ path.suffix == ".py"
30
+ and path.name != "__init__.py"
31
+ and not path.name.startswith("test_")
32
+ and not path.name.startswith("_")
33
+ )
34
+
35
+
36
+ def _load_module_from_path(module_name: str, file_path: Path) -> Optional[object]:
37
+ """Load a Python module from a file path.
38
+
39
+ Args:
40
+ module_name: Name to give the module
41
+ file_path: Path to the Python file
42
+
43
+ Returns:
44
+ Loaded module or None if loading failed
45
+ """
46
+ try:
47
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
48
+ if spec is None or spec.loader is None:
49
+ logger.warning(f"Could not load spec for {file_path}")
50
+ return None
51
+
52
+ module = importlib.util.module_from_spec(spec)
53
+
54
+ # For user tools (not in src/utils/tools/), set package to None
55
+ # to force absolute imports instead of relative imports
56
+ from tools import __file__ as tools_init_file
57
+ tools_dir = Path(tools_init_file).parent
58
+
59
+ # User tools are those not in the main tools directory
60
+ # (helper modules are in src/tools/helpers/)
61
+ if file_path.parent != tools_dir and file_path.parent != tools_dir / "helpers":
62
+ # User tool - set to None to force absolute imports
63
+ module.__package__ = None
64
+
65
+ sys.modules[module_name] = module
66
+ spec.loader.exec_module(module)
67
+
68
+ logger.debug(f"Successfully loaded module: {module_name}")
69
+ return module
70
+
71
+ except Exception as e:
72
+ logger.warning(f"Failed to load module {module_name} from {file_path}: {e}")
73
+ return None
74
+
75
+
76
+ def discover_tools(directories: List[str]) -> int:
77
+ """Discover and load tools from specified directories.
78
+
79
+ This scans directories for Python files and imports them,
80
+ which triggers @tool decorator registration.
81
+
82
+ Args:
83
+ directories: List of directory paths to scan
84
+
85
+ Returns:
86
+ Number of tools successfully loaded
87
+
88
+ Note:
89
+ - Only .py files are considered (excluding __pycache__, tests)
90
+ - Import errors are logged but don't stop discovery
91
+ - User tools can override built-in tools (with warning)
92
+ """
93
+ initial_count = ToolRegistry.tool_count()
94
+ loaded_count = 0
95
+
96
+ for directory in directories:
97
+ dir_path = Path(directory)
98
+
99
+ if not dir_path.exists():
100
+ logger.debug(f"Tool directory does not exist: {directory}")
101
+ continue
102
+
103
+ if not dir_path.is_dir():
104
+ logger.warning(f"Tool path is not a directory: {directory}")
105
+ continue
106
+
107
+ logger.info(f"Discovering tools in: {directory}")
108
+
109
+ # Find all Python files
110
+ python_files = [f for f in dir_path.iterdir() if _is_python_file(f)]
111
+
112
+ for py_file in python_files:
113
+ # Create unique module name
114
+ module_name = f"tools_{py_file.stem}_{hash(str(py_file)) & 0xFFFFFFFF}"
115
+
116
+ # Skip modules already loaded (e.g. cron re-calling load_all_tools)
117
+ if module_name in sys.modules:
118
+ logger.debug(f"Module already loaded, skipping: {module_name}")
119
+ continue
120
+
121
+ module = _load_module_from_path(module_name, py_file)
122
+ if module:
123
+ loaded_count += 1
124
+
125
+ final_count = ToolRegistry.tool_count()
126
+ new_tools = final_count - initial_count
127
+
128
+ logger.info(
129
+ f"Tool discovery complete: Loaded {loaded_count} modules, "
130
+ f"registered {new_tools} new tools (total: {final_count})"
131
+ )
132
+
133
+ return new_tools
134
+
135
+
136
+ def load_builtin_tools() -> int:
137
+ """Load built-in tools from src/utils/tools/.
138
+
139
+ Returns:
140
+ Number of tools loaded
141
+
142
+ Note:
143
+ Built-in tools are already imported in __init__.py, which triggers
144
+ @tool decorator registration. This function returns the count of
145
+ currently registered built-in tools.
146
+ """
147
+ # Built-in tools are already imported in utils/tools/__init__.py
148
+ # which triggers @tool decorator registration.
149
+ # Just return the count of tools currently in registry.
150
+ return ToolRegistry.tool_count()
151
+
152
+
153
+ def load_plugin_tools() -> int:
154
+ """Load plugin tools from tool_plugins/ directory.
155
+
156
+ Plugin modules are imported, which triggers @tool(tier="plugin") decorator
157
+ registration into the PluginManifest (NOT ToolRegistry). This keeps plugin
158
+ schemas out of the LLM context window until activated via search_plugins.
159
+
160
+ Returns:
161
+ Number of plugin modules loaded
162
+
163
+ Note:
164
+ - tool_plugins/ directory at repository root
165
+ - Plugin tools registered in PluginManifest, not ToolRegistry
166
+ """
167
+ from .plugin_manifest import plugin_manifest
168
+
169
+ # Get repository root (assumes we're in src/tools/helpers/)
170
+ current_dir = Path(__file__).parent
171
+ repo_root = current_dir.parent.parent.parent
172
+
173
+ # Define plugin tool directories
174
+ plugin_directories = [
175
+ str(repo_root / "tool_plugins"),
176
+ ]
177
+
178
+ # Discover tools in plugin directories
179
+ modules_loaded = discover_tools(plugin_directories)
180
+
181
+ logger.info(
182
+ f"Plugin manifest: {plugin_manifest.plugin_count()} plugins available "
183
+ f"(categories: {plugin_manifest.get_categories()})"
184
+ )
185
+
186
+ return modules_loaded
187
+
188
+
189
+ def load_all_tools() -> int:
190
+ """Load all tools (built-in and plugin tools).
191
+
192
+ Returns:
193
+ Total number of tools loaded
194
+
195
+ Discovery order:
196
+ 1. Built-in tools (src/tools/*.py)
197
+ 2. Plugin tools (tool_plugins/*.py)
198
+
199
+ Note:
200
+ Plugin tools loaded later can override built-in tools.
201
+ """
202
+ logger.info("Starting tool loading...")
203
+
204
+ # Load built-in tools first
205
+ builtin_count = load_builtin_tools()
206
+
207
+ # Then load plugin tools (can override built-ins)
208
+ plugin_count = load_plugin_tools()
209
+
210
+ total_count = builtin_count + plugin_count
211
+ total_registered = ToolRegistry.tool_count()
212
+
213
+ logger.info(
214
+ f"Tool loading complete: {builtin_count} built-in + "
215
+ f"{plugin_count} plugin modules = {total_count} modules, "
216
+ f"{total_registered} tools registered"
217
+ )
218
+
219
+ return total_count
220
+
221
+
222
+ def list_registered_tools() -> List[str]:
223
+ """List names of all registered tools.
224
+
225
+ Returns:
226
+ List of tool names
227
+ """
228
+ return [tool.name for tool in ToolRegistry.get_all()]
229
+
230
+
231
+
@@ -0,0 +1,231 @@
1
+ """Concurrent tool execution engine.
2
+
3
+ This module provides parallel execution of multiple tool calls using
4
+ ThreadPoolExecutor for I/O-bound operations like file reads and web searches.
5
+ """
6
+
7
+ import concurrent.futures
8
+ from typing import List, Tuple
9
+ from dataclasses import dataclass
10
+
11
+ from .base import ToolRegistry, build_context
12
+
13
+
14
+ @dataclass
15
+ class ToolCall:
16
+ """Represents a single tool call.
17
+
18
+ Attributes:
19
+ tool_id: Unique identifier for this tool call
20
+ function_name: Name of the tool function to execute
21
+ arguments: Dictionary of arguments to pass to the tool handler
22
+ call_index: Index in original tool_calls array (for order preservation)
23
+ """
24
+ tool_id: str
25
+ function_name: str
26
+ arguments: dict
27
+ call_index: int
28
+
29
+
30
+ @dataclass
31
+ class ToolResult:
32
+ """Result of a tool execution.
33
+
34
+ Attributes:
35
+ tool_id: Unique identifier for the tool call
36
+ call_index: Index in original tool_calls array (for order preservation)
37
+ success: Whether the tool executed successfully
38
+ result: String result from tool execution (if successful)
39
+ error: Error message (if failed)
40
+ should_exit: Whether the tool requested the orchestration loop to exit
41
+ requires_approval: Whether this tool requires user approval (for orchestrator)
42
+ """
43
+ tool_id: str
44
+ call_index: int
45
+ success: bool
46
+ result: str
47
+ error: str = None
48
+ should_exit: bool = False
49
+ requires_approval: bool = False
50
+
51
+
52
+ class ParallelToolExecutor:
53
+ """Executes multiple tool calls concurrently with proper error handling.
54
+
55
+ This class provides thread-safe concurrent execution of tool calls using
56
+ ThreadPoolExecutor. Key features:
57
+ - Executes independent tools concurrently for performance
58
+ - Preserves result order using call_index tracking
59
+ - Isolates errors (one failure doesn't stop others)
60
+ - Fast-path optimization for single tool calls (no threading overhead)
61
+ """
62
+
63
+ def __init__(self, max_workers: int = 5):
64
+ """Initialize executor.
65
+
66
+ Args:
67
+ max_workers: Maximum number of concurrent tool executions
68
+ """
69
+ self.max_workers = max_workers
70
+
71
+ def execute_tools(
72
+ self,
73
+ tool_calls: List[ToolCall],
74
+ context: dict
75
+ ) -> Tuple[List[ToolResult], bool]:
76
+ """Execute multiple tools concurrently.
77
+
78
+ Args:
79
+ tool_calls: List of ToolCall objects
80
+ context: Dictionary containing repo_root, console, chat_manager, etc.
81
+
82
+ Returns:
83
+ Tuple of (results in call_index order, had_any_errors)
84
+ """
85
+ if len(tool_calls) == 1:
86
+ # Fast path for single tool (no threading overhead)
87
+ return self._execute_single(tool_calls[0], context)
88
+
89
+ # Parallel execution for multiple tools
90
+ results = []
91
+
92
+ with concurrent.futures.ThreadPoolExecutor(
93
+ max_workers=min(self.max_workers, len(tool_calls))
94
+ ) as executor:
95
+ # Submit all tool executions
96
+ future_to_call = {
97
+ executor.submit(
98
+ self._execute_single_tool,
99
+ tool_call,
100
+ context
101
+ ): tool_call
102
+ for tool_call in tool_calls
103
+ }
104
+
105
+ # Collect results as they complete
106
+ for future in concurrent.futures.as_completed(future_to_call):
107
+ tool_call = future_to_call[future]
108
+ try:
109
+ result = future.result()
110
+ results.append(result)
111
+ except Exception as e:
112
+ results.append(ToolResult(
113
+ tool_id=tool_call.tool_id,
114
+ call_index=tool_call.call_index,
115
+ success=False,
116
+ result="",
117
+ error=str(e)
118
+ ))
119
+
120
+ # Sort by call_index to maintain order
121
+ results.sort(key=lambda r: r.call_index)
122
+
123
+ # Check for errors
124
+ had_errors = any(not r.success for r in results)
125
+
126
+ return results, had_errors
127
+
128
+ def _execute_single(
129
+ self,
130
+ tool_call: ToolCall,
131
+ context: dict
132
+ ) -> Tuple[List[ToolResult], bool]:
133
+ """Execute single tool (fast path, no threading overhead).
134
+
135
+ Args:
136
+ tool_call: Single ToolCall to execute
137
+ context: Execution context dict
138
+
139
+ Returns:
140
+ Tuple of (single-element result list, had_errors)
141
+ """
142
+ result = self._execute_single_tool(tool_call, context)
143
+ return [result], not result.success
144
+
145
+ def _execute_single_tool(
146
+ self,
147
+ tool_call: ToolCall,
148
+ context: dict
149
+ ) -> ToolResult:
150
+ """Execute a single tool call with error handling.
151
+
152
+ Args:
153
+ tool_call: ToolCall to execute
154
+ context: Execution context dict
155
+
156
+ Returns:
157
+ ToolResult with execution outcome
158
+ """
159
+ tool = ToolRegistry.get(tool_call.function_name)
160
+ if tool:
161
+ try:
162
+ # For tools requiring approval, return preview without executing
163
+ # The orchestrator will handle the approval workflow
164
+ if tool.requires_approval:
165
+ # Build context from context dict
166
+ cm = context.get('chat_manager')
167
+
168
+ tool_context = build_context(
169
+ repo_root=context.get('repo_root'),
170
+ console=context.get('console'),
171
+ gitignore_spec=context.get('gitignore_spec'),
172
+ debug_mode=context.get('debug_mode', False),
173
+ chat_manager=cm,
174
+ rg_exe_path=context.get('rg_exe_path'),
175
+ panel_updater=context.get('panel_updater'),
176
+ vault_root=context.get('vault_root')
177
+ )
178
+
179
+ # For edit_file: return preview (orchestrator handles approval)
180
+ if tool_call.function_name == "edit_file":
181
+ tool_result = tool.execute(tool_call.arguments, tool_context)
182
+ return ToolResult(
183
+ tool_id=tool_call.tool_id,
184
+ call_index=tool_call.call_index,
185
+ success=True,
186
+ result=tool_result,
187
+ should_exit=False,
188
+ requires_approval=True # Flag for orchestrator to handle
189
+ )
190
+ # Other approval-required tools would go here in the future
191
+
192
+ # Normal execution for tools without approval
193
+ # Build context from context dict
194
+ cm = context.get('chat_manager')
195
+
196
+ tool_context = build_context(
197
+ repo_root=context.get('repo_root'),
198
+ console=context.get('console'),
199
+ gitignore_spec=context.get('gitignore_spec'),
200
+ debug_mode=context.get('debug_mode', False),
201
+ chat_manager=cm,
202
+ rg_exe_path=context.get('rg_exe_path'),
203
+ panel_updater=context.get('panel_updater'),
204
+ vault_root=context.get('vault_root')
205
+ )
206
+
207
+ tool_result = tool.execute(tool_call.arguments, tool_context)
208
+ return ToolResult(
209
+ tool_id=tool_call.tool_id,
210
+ call_index=tool_call.call_index,
211
+ success=True,
212
+ result=tool_result,
213
+ should_exit=False
214
+ )
215
+ except Exception as e:
216
+ return ToolResult(
217
+ tool_id=tool_call.tool_id,
218
+ call_index=tool_call.call_index,
219
+ success=False,
220
+ result="",
221
+ error=f"Error executing tool '{tool_call.function_name}': {str(e)}"
222
+ )
223
+
224
+ # Tool not found in registry
225
+ return ToolResult(
226
+ tool_id=tool_call.tool_id,
227
+ call_index=tool_call.call_index,
228
+ success=False,
229
+ result="",
230
+ error=f"Unknown tool '{tool_call.function_name}'"
231
+ )
@@ -0,0 +1,226 @@
1
+ """Path resolution and validation utilities.
2
+
3
+ This module provides centralized path validation and resolution logic,
4
+ ensuring consistent behavior across all tools that work with file paths.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional, Tuple
11
+
12
+ from .file_helpers import _is_reserved_windows_name
13
+
14
+ # Performance metrics tracking
15
+ _path_resolution_times = []
16
+ _path_validation_errors = {}
17
+
18
+
19
+ class PathResolver:
20
+ """Centralized path resolution and validation.
21
+
22
+ This class provides a single source of truth for path validation logic,
23
+ including Windows-specific checks, path resolution, and gitignore filtering.
24
+
25
+ Example:
26
+ resolver = PathResolver(repo_root=Path("/project"), gitignore_spec=spec)
27
+ resolved_path, error = resolver.resolve_and_validate("src/file.py")
28
+ if error:
29
+ return f"Error: {error}"
30
+ # Use resolved_path...
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ repo_root: Path,
36
+ gitignore_spec = None,
37
+ vault_path: Path = None
38
+ ):
39
+ """Initialize the path resolver.
40
+
41
+ Args:
42
+ repo_root: Repository root directory for resolving relative paths
43
+ gitignore_spec: Optional PathSpec for .gitignore filtering
44
+ vault_path: Optional Obsidian vault root (allowed as second base path)
45
+ """
46
+ self.repo_root = repo_root
47
+ self.vault_path = vault_path
48
+ self.gitignore_spec = gitignore_spec
49
+
50
+ def resolve_and_validate(
51
+ self,
52
+ path_str: str,
53
+ check_gitignore: bool = True,
54
+ must_exist: bool = True,
55
+ must_be_file: bool = False,
56
+ must_be_dir: bool = False,
57
+ enforce_boundary: bool = False,
58
+ ) -> Tuple[Optional[Path], Optional[str]]:
59
+ """Validate and resolve a path string.
60
+
61
+ Performs comprehensive validation including:
62
+ - Windows filename validation (invalid chars, reserved names)
63
+ - Path resolution (absolute vs relative)
64
+ - Gitignore filtering (optional, within repo only)
65
+ - Path existence check (optional)
66
+ - Type validation (file/directory, optional)
67
+
68
+ Args:
69
+ path_str: Path string to validate
70
+ check_gitignore: Whether to apply gitignore filtering
71
+ must_exist: Whether the path must exist on disk
72
+ must_be_file: Whether the path must be a file (requires must_exist=True)
73
+ must_be_dir: Whether the path must be a directory (requires must_exist=True)
74
+ enforce_boundary: Whether to restrict paths to repo_root or vault_path (default: False)
75
+
76
+ Returns:
77
+ Tuple of (resolved_path, error_message)
78
+ - resolved_path: Path object if valid, None if invalid
79
+ - error_message: None if valid, error description if invalid
80
+ """
81
+ start_time = time.time()
82
+
83
+ try:
84
+ # Step 1: Validate filename for Windows-specific issues
85
+ if os.name == 'nt': # Windows-specific validation
86
+ # Check for invalid characters
87
+ invalid_chars = '<>:"|?*[]{}"\n\r\t'
88
+ if any(char in path_str for char in invalid_chars):
89
+ elapsed = time.time() - start_time
90
+ _track_validation_error("invalid_chars")
91
+ _path_resolution_times.append(elapsed)
92
+ return None, f"Filename contains invalid characters: {invalid_chars}"
93
+
94
+ # Check for reserved device names
95
+ filename = Path(path_str).name
96
+ if _is_reserved_windows_name(filename):
97
+ elapsed = time.time() - start_time
98
+ _track_validation_error("reserved_name")
99
+ _path_resolution_times.append(elapsed)
100
+ return None, f"Filename is a reserved Windows device name: {filename}"
101
+
102
+ # Step 2: Resolve the path
103
+ path = Path(path_str)
104
+ if not path.is_absolute():
105
+ path = self.repo_root / path
106
+
107
+ # Resolve to absolute path (handles .. and symlinks)
108
+ path = path.resolve()
109
+
110
+ # Step 2b: Security boundary — path must be within repo_root or vault_path
111
+ if enforce_boundary:
112
+ try:
113
+ path.relative_to(self.repo_root)
114
+ except ValueError:
115
+ if self.vault_path is not None:
116
+ try:
117
+ path.relative_to(self.vault_path)
118
+ except ValueError:
119
+ elapsed = time.time() - start_time
120
+ _track_validation_error("outside_allowed_roots")
121
+ _path_resolution_times.append(elapsed)
122
+ return None, f"Path is outside allowed directories: {path_str}"
123
+ else:
124
+ elapsed = time.time() - start_time
125
+ _track_validation_error("outside_repo")
126
+ _path_resolution_times.append(elapsed)
127
+ return None, f"Path is outside repository: {path_str}"
128
+
129
+ # Step 3: Check existence if required
130
+ if must_exist:
131
+ if not path.exists():
132
+ elapsed = time.time() - start_time
133
+ _track_validation_error("not_found")
134
+ _path_resolution_times.append(elapsed)
135
+ return None, f"Path not found: {path_str}"
136
+
137
+ # Step 4: Validate type if required
138
+ if must_be_file and not path.is_file():
139
+ elapsed = time.time() - start_time
140
+ _track_validation_error("not_a_file")
141
+ _path_resolution_times.append(elapsed)
142
+ return None, f"Path is not a file: {path_str}"
143
+
144
+ if must_be_dir and not path.is_dir():
145
+ elapsed = time.time() - start_time
146
+ _track_validation_error("not_a_dir")
147
+ _path_resolution_times.append(elapsed)
148
+ return None, f"Path is not a directory: {path_str}"
149
+
150
+ # Step 5: Check gitignore if requested and within repo
151
+ if check_gitignore and self.gitignore_spec is not None:
152
+ # Only check gitignore for paths within the repo
153
+ try:
154
+ path.relative_to(self.repo_root)
155
+ from .file_helpers import _is_ignored_cached, _register_gitignore_spec
156
+
157
+ # Full gitignore check
158
+ spec_key = _register_gitignore_spec(self.gitignore_spec)
159
+ if _is_ignored_cached(str(path), str(self.repo_root), spec_key):
160
+ elapsed = time.time() - start_time
161
+ _track_validation_error("gitignore_filtered")
162
+ _path_resolution_times.append(elapsed)
163
+ return None, f"Path is excluded by .gitignore: {path_str}"
164
+ except ValueError:
165
+ # Path is outside repo, skip gitignore check
166
+ pass
167
+
168
+ # Success - track timing
169
+ elapsed = time.time() - start_time
170
+ _path_resolution_times.append(elapsed)
171
+ return path, None
172
+
173
+ except OSError as e:
174
+ elapsed = time.time() - start_time
175
+ _track_validation_error("os_error")
176
+ _path_resolution_times.append(elapsed)
177
+ return None, f"Error accessing path '{path_str}': {e}"
178
+ except Exception as e:
179
+ elapsed = time.time() - start_time
180
+ _track_validation_error("unexpected_error")
181
+ _path_resolution_times.append(elapsed)
182
+ return None, f"Unexpected error resolving path '{path_str}': {e}"
183
+
184
+
185
+ def _track_validation_error(error_type: str):
186
+ """Track validation errors for metrics.
187
+
188
+ Args:
189
+ error_type: Type of validation error
190
+ """
191
+ _path_validation_errors[error_type] = _path_validation_errors.get(error_type, 0) + 1
192
+
193
+
194
+ def get_path_resolver_metrics() -> dict:
195
+ """Get performance metrics for path resolution operations.
196
+
197
+ Returns:
198
+ Dictionary with metrics:
199
+ - total_resolutions: Total number of path resolutions
200
+ - avg_resolution_time: Average resolution time in seconds
201
+ - max_resolution_time: Maximum resolution time
202
+ - min_resolution_time: Minimum resolution time
203
+ - validation_errors: Dict of error types and counts
204
+ """
205
+ if not _path_resolution_times:
206
+ return {
207
+ "total_resolutions": 0,
208
+ "avg_resolution_time": 0,
209
+ "max_resolution_time": 0,
210
+ "min_resolution_time": 0,
211
+ "validation_errors": _path_validation_errors.copy()
212
+ }
213
+
214
+ return {
215
+ "total_resolutions": len(_path_resolution_times),
216
+ "avg_resolution_time": sum(_path_resolution_times) / len(_path_resolution_times),
217
+ "max_resolution_time": max(_path_resolution_times),
218
+ "min_resolution_time": min(_path_resolution_times),
219
+ "validation_errors": _path_validation_errors.copy()
220
+ }
221
+
222
+
223
+ def clear_path_resolver_metrics():
224
+ """Clear all accumulated metrics for testing or monitoring reset."""
225
+ _path_resolution_times.clear()
226
+ _path_validation_errors.clear()