tree-sitter-analyzer 0.9.9__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of tree-sitter-analyzer might be problematic. Click here for more details.

Files changed (28) hide show
  1. tree_sitter_analyzer/__init__.py +1 -1
  2. tree_sitter_analyzer/api.py +11 -2
  3. tree_sitter_analyzer/cli/commands/base_command.py +2 -2
  4. tree_sitter_analyzer/cli/commands/partial_read_command.py +2 -2
  5. tree_sitter_analyzer/cli/info_commands.py +7 -4
  6. tree_sitter_analyzer/cli_main.py +1 -2
  7. tree_sitter_analyzer/core/analysis_engine.py +2 -2
  8. tree_sitter_analyzer/core/query_service.py +162 -162
  9. tree_sitter_analyzer/file_handler.py +6 -4
  10. tree_sitter_analyzer/formatters/base_formatter.py +6 -4
  11. tree_sitter_analyzer/mcp/__init__.py +1 -1
  12. tree_sitter_analyzer/mcp/resources/__init__.py +1 -1
  13. tree_sitter_analyzer/mcp/resources/project_stats_resource.py +16 -5
  14. tree_sitter_analyzer/mcp/server.py +16 -11
  15. tree_sitter_analyzer/mcp/tools/__init__.py +1 -1
  16. tree_sitter_analyzer/mcp/utils/__init__.py +2 -2
  17. tree_sitter_analyzer/mcp/utils/error_handler.py +569 -569
  18. tree_sitter_analyzer/mcp/utils/path_resolver.py +240 -20
  19. tree_sitter_analyzer/output_manager.py +7 -3
  20. tree_sitter_analyzer/project_detector.py +27 -27
  21. tree_sitter_analyzer/security/boundary_manager.py +37 -28
  22. tree_sitter_analyzer/security/validator.py +23 -12
  23. tree_sitter_analyzer/table_formatter.py +6 -4
  24. tree_sitter_analyzer/utils.py +1 -2
  25. {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/METADATA +38 -12
  26. {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/RECORD +28 -28
  27. {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/WHEEL +0 -0
  28. {tree_sitter_analyzer-0.9.9.dist-info → tree_sitter_analyzer-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -9,10 +9,91 @@ operating systems.
9
9
 
10
10
  import logging
11
11
  import os
12
+ from pathlib import Path
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
17
+ def _normalize_path_cross_platform(path_str: str) -> str:
18
+ """
19
+ Normalize path for cross-platform compatibility.
20
+
21
+ Args:
22
+ path_str: Input path string
23
+
24
+ Returns:
25
+ Normalized path string
26
+ """
27
+ if not path_str:
28
+ return path_str
29
+
30
+ # Handle macOS path normalization issues
31
+ if os.name == "posix":
32
+ # Handle /System/Volumes/Data prefix on macOS
33
+ if path_str.startswith("/System/Volumes/Data/"):
34
+ # Remove the /System/Volumes/Data prefix
35
+ normalized = path_str[len("/System/Volumes/Data") :]
36
+ return normalized
37
+
38
+ # Handle /private/var vs /var symlink difference on macOS
39
+ if path_str.startswith("/private/var/"):
40
+ # Always normalize to /var form on macOS for consistency
41
+ var_path = path_str.replace("/private/var/", "/var/", 1)
42
+ return var_path
43
+ elif path_str.startswith("/var/"):
44
+ # Keep /var form as is
45
+ return path_str
46
+
47
+ # Handle Windows short path names (8.3 format)
48
+ if os.name == "nt" and path_str:
49
+ try:
50
+ # Try to get the long path name on Windows
51
+ import ctypes
52
+ from ctypes import wintypes
53
+
54
+ # GetLongPathNameW function
55
+ _GetLongPathNameW = ctypes.windll.kernel32.GetLongPathNameW
56
+ _GetLongPathNameW.argtypes = [
57
+ wintypes.LPCWSTR,
58
+ wintypes.LPWSTR,
59
+ wintypes.DWORD,
60
+ ]
61
+ _GetLongPathNameW.restype = wintypes.DWORD
62
+
63
+ # Buffer for the long path
64
+ buffer_size = 1000
65
+ buffer = ctypes.create_unicode_buffer(buffer_size)
66
+
67
+ # Get the long path name
68
+ result = _GetLongPathNameW(path_str, buffer, buffer_size)
69
+ if result > 0 and result < buffer_size:
70
+ long_path = buffer.value
71
+ if long_path and long_path != path_str:
72
+ return long_path
73
+ except (ImportError, AttributeError, OSError):
74
+ # If Windows API calls fail, continue with original path
75
+ pass
76
+
77
+ return path_str
78
+
79
+
80
+ def _is_windows_absolute_path(path_str: str) -> bool:
81
+ """
82
+ Check if a path is a Windows-style absolute path.
83
+
84
+ Args:
85
+ path_str: Path string to check
86
+
87
+ Returns:
88
+ True if it's a Windows absolute path (e.g., C:\\path or C:/path)
89
+ """
90
+ if not path_str or len(path_str) < 3:
91
+ return False
92
+
93
+ # Check for drive letter pattern: X:\ or X:/
94
+ return path_str[1] == ":" and path_str[0].isalpha() and path_str[2] in ("\\", "/")
95
+
96
+
16
97
  class PathResolver:
17
98
  """
18
99
  Utility class for resolving file paths in MCP tools.
@@ -28,10 +109,20 @@ class PathResolver:
28
109
  Args:
29
110
  project_root: Optional project root directory for resolving relative paths
30
111
  """
31
- self.project_root = project_root
112
+ self.project_root = None
113
+ self._cache = {} # Simple cache for resolved paths
114
+ self._cache_size_limit = 100 # Limit cache size to prevent memory issues
115
+
32
116
  if project_root:
33
- # Normalize project root path
34
- self.project_root = os.path.normpath(project_root)
117
+ # Use pathlib for consistent path handling, but preserve relative paths for compatibility
118
+ path_obj = Path(project_root)
119
+ if path_obj.is_absolute():
120
+ resolved_root = str(path_obj.resolve())
121
+ # Apply cross-platform normalization
122
+ self.project_root = _normalize_path_cross_platform(resolved_root)
123
+ else:
124
+ # For relative paths, normalize but don't resolve to absolute
125
+ self.project_root = str(path_obj)
35
126
  logger.debug(
36
127
  f"PathResolver initialized with project root: {self.project_root}"
37
128
  )
@@ -48,34 +139,126 @@ class PathResolver:
48
139
 
49
140
  Raises:
50
141
  ValueError: If file_path is empty or None
142
+ TypeError: If file_path is not a string
51
143
  """
52
144
  if not file_path:
53
145
  raise ValueError("file_path cannot be empty or None")
54
146
 
55
- # If already absolute, return as is
56
- if os.path.isabs(file_path):
57
- resolved_path = os.path.normpath(file_path)
147
+ if not isinstance(file_path, str):
148
+ raise TypeError(
149
+ f"file_path must be a string, got {type(file_path).__name__}"
150
+ )
151
+
152
+ # Check cache first
153
+ if file_path in self._cache:
154
+ logger.debug(f"Cache hit for path: {file_path}")
155
+ return self._cache[file_path]
156
+
157
+ # Normalize path separators first
158
+ normalized_input = file_path.replace("\\", "/")
159
+
160
+ # Special handling for Windows absolute paths on non-Windows systems
161
+ if _is_windows_absolute_path(file_path) and os.name != "nt":
162
+ # On non-Windows systems, Windows absolute paths should be treated as-is
163
+ # Don't try to resolve them relative to project root
164
+ logger.debug(f"Windows absolute path on non-Windows system: {file_path}")
165
+ self._add_to_cache(file_path, file_path)
166
+ return file_path
167
+
168
+ path_obj = Path(normalized_input)
169
+
170
+ # Handle Unix-style absolute paths on Windows (starting with /) FIRST
171
+ # This must come before the is_absolute() check because Unix paths aren't
172
+ # considered absolute on Windows by pathlib
173
+ if (
174
+ normalized_input.startswith("/") and os.name == "nt"
175
+ ): # Check if we're on Windows
176
+ # On Windows, convert Unix-style absolute paths to Windows format
177
+ # by prepending the current drive with proper separator
178
+ current_drive = Path.cwd().drive
179
+ if current_drive:
180
+ # Remove leading slash and join with current drive
181
+ unix_path_without_slash = normalized_input[1:]
182
+ # Ensure proper Windows path format with backslash after drive
183
+ resolved_path = str(
184
+ Path(current_drive + "\\") / unix_path_without_slash
185
+ )
186
+ logger.debug(
187
+ f"Converted Unix absolute path: {file_path} -> {resolved_path}"
188
+ )
189
+ # Apply cross-platform normalization
190
+ resolved_path = _normalize_path_cross_platform(resolved_path)
191
+ self._add_to_cache(file_path, resolved_path)
192
+ return resolved_path
193
+ # If no drive available, continue with normal processing
194
+
195
+ # Check if path is absolute
196
+ if path_obj.is_absolute():
197
+ resolved_path = str(path_obj.resolve())
58
198
  logger.debug(f"Path already absolute: {file_path} -> {resolved_path}")
199
+ # Apply cross-platform normalization
200
+ resolved_path = _normalize_path_cross_platform(resolved_path)
201
+ self._add_to_cache(file_path, resolved_path)
59
202
  return resolved_path
60
203
 
61
204
  # If we have a project root, resolve relative to it
62
205
  if self.project_root:
63
- resolved_path = os.path.join(self.project_root, file_path)
64
- # Normalize path separators for cross-platform compatibility
65
- resolved_path = os.path.normpath(resolved_path)
206
+ resolved_path = str((Path(self.project_root) / normalized_input).resolve())
66
207
  logger.debug(
67
208
  f"Resolved relative path '{file_path}' to '{resolved_path}' using project root"
68
209
  )
210
+ # Apply cross-platform normalization
211
+ resolved_path = _normalize_path_cross_platform(resolved_path)
212
+ self._add_to_cache(file_path, resolved_path)
69
213
  return resolved_path
70
214
 
71
215
  # Fallback: try to resolve relative to current working directory
72
- resolved_path = os.path.abspath(file_path)
73
- resolved_path = os.path.normpath(resolved_path)
216
+ resolved_path = str(Path(normalized_input).resolve())
74
217
  logger.debug(
75
218
  f"Resolved relative path '{file_path}' to '{resolved_path}' using current working directory"
76
219
  )
220
+
221
+ # Apply cross-platform normalization
222
+ resolved_path = _normalize_path_cross_platform(resolved_path)
223
+
224
+ # Cache the result
225
+ self._add_to_cache(file_path, resolved_path)
226
+
77
227
  return resolved_path
78
228
 
229
+ def _add_to_cache(self, file_path: str, resolved_path: str) -> None:
230
+ """
231
+ Add a resolved path to the cache.
232
+
233
+ Args:
234
+ file_path: Original file path
235
+ resolved_path: Resolved absolute path
236
+ """
237
+ # Limit cache size to prevent memory issues
238
+ if len(self._cache) >= self._cache_size_limit:
239
+ # Remove oldest entries (simple FIFO)
240
+ oldest_key = next(iter(self._cache))
241
+ del self._cache[oldest_key]
242
+ logger.debug(f"Cache full, removed oldest entry: {oldest_key}")
243
+
244
+ self._cache[file_path] = resolved_path
245
+ logger.debug(f"Cached path resolution: {file_path} -> {resolved_path}")
246
+
247
+ def clear_cache(self) -> None:
248
+ """Clear the path resolution cache."""
249
+ cache_size = len(self._cache)
250
+ self._cache.clear()
251
+ logger.info(f"Cleared path resolution cache ({cache_size} entries)")
252
+
253
+ def get_cache_stats(self) -> dict[str, int]:
254
+ """
255
+ Get cache statistics.
256
+
257
+ Returns:
258
+ Dictionary with cache statistics
259
+ """
260
+ return {"size": len(self._cache), "limit": self._cache_size_limit}
261
+
79
262
  def is_relative(self, file_path: str) -> bool:
80
263
  """
81
264
  Check if a file path is relative.
@@ -86,7 +269,7 @@ class PathResolver:
86
269
  Returns:
87
270
  True if the path is relative, False if absolute
88
271
  """
89
- return not os.path.isabs(file_path)
272
+ return not Path(file_path).is_absolute()
90
273
 
91
274
  def get_relative_path(self, absolute_path: str) -> str:
92
275
  """
@@ -101,15 +284,32 @@ class PathResolver:
101
284
  Raises:
102
285
  ValueError: If absolute_path is not actually absolute
103
286
  """
104
- if not os.path.isabs(absolute_path):
287
+ abs_path = Path(absolute_path)
288
+ if not abs_path.is_absolute():
105
289
  raise ValueError(f"Path is not absolute: {absolute_path}")
106
290
 
107
291
  if not self.project_root:
108
292
  return absolute_path
109
293
 
110
294
  try:
111
- # Get relative path from project root
112
- relative_path = os.path.relpath(absolute_path, self.project_root)
295
+ # Get relative path from project root using pathlib
296
+ project_path = Path(self.project_root)
297
+
298
+ # Normalize both paths for consistent comparison
299
+ normalized_abs_path = _normalize_path_cross_platform(
300
+ str(abs_path.resolve())
301
+ )
302
+ normalized_project_root = _normalize_path_cross_platform(
303
+ str(project_path.resolve())
304
+ )
305
+
306
+ # Convert back to Path objects for relative_to calculation
307
+ normalized_abs_path_obj = Path(normalized_abs_path)
308
+ normalized_project_root_obj = Path(normalized_project_root)
309
+
310
+ relative_path = str(
311
+ normalized_abs_path_obj.relative_to(normalized_project_root_obj)
312
+ )
113
313
  logger.debug(
114
314
  f"Converted absolute path '{absolute_path}' to relative path '{relative_path}'"
115
315
  )
@@ -133,19 +333,31 @@ class PathResolver:
133
333
  """
134
334
  try:
135
335
  resolved_path = self.resolve(file_path)
336
+ resolved_path_obj = Path(resolved_path)
136
337
 
137
- # Check if file exists
138
- if not os.path.exists(resolved_path):
338
+ if not resolved_path_obj.exists():
139
339
  return False, f"File does not exist: {resolved_path}"
140
340
 
141
341
  # Check if it's a file (not directory)
142
- if not os.path.isfile(resolved_path):
342
+ if not resolved_path_obj.is_file():
143
343
  return False, f"Path is not a file: {resolved_path}"
144
344
 
345
+ # Check if it's a symlink (reject symlinks for security)
346
+ try:
347
+ if resolved_path_obj.is_symlink():
348
+ return False, f"Path is a symlink: {resolved_path}"
349
+ except (OSError, AttributeError):
350
+ # is_symlink() might not be available on all platforms
351
+ # or might fail due to permissions, skip this check
352
+ pass
353
+
145
354
  # Check if it's within project root (if we have one)
146
355
  if self.project_root:
147
356
  try:
148
- os.path.commonpath([resolved_path, self.project_root])
357
+ project_path = Path(self.project_root).resolve()
358
+ resolved_abs_path = resolved_path_obj.resolve()
359
+ # Check if the resolved path is within the project root
360
+ resolved_abs_path.relative_to(project_path)
149
361
  except ValueError:
150
362
  return False, f"File path is outside project root: {resolved_path}"
151
363
 
@@ -171,7 +383,15 @@ class PathResolver:
171
383
  project_root: New project root directory
172
384
  """
173
385
  if project_root:
174
- self.project_root = os.path.normpath(project_root)
386
+ # Use pathlib for consistent path handling, but preserve relative paths for compatibility
387
+ path_obj = Path(project_root)
388
+ if path_obj.is_absolute():
389
+ resolved_root = str(path_obj.resolve())
390
+ # Apply cross-platform normalization
391
+ self.project_root = _normalize_path_cross_platform(resolved_root)
392
+ else:
393
+ # For relative paths, normalize but don't resolve to absolute
394
+ self.project_root = str(path_obj)
175
395
  logger.info(f"Project root updated to: {self.project_root}")
176
396
  else:
177
397
  self.project_root = None
@@ -120,9 +120,13 @@ class OutputManager:
120
120
  """Output supported extensions"""
121
121
  if not self.quiet:
122
122
  print("Supported file extensions:")
123
- for i in range(0, len(extensions), 10):
124
- chunk = extensions[i : i + 10]
125
- print(f" {' '.join(chunk)}")
123
+ # Use more efficient chunking
124
+ from itertools import islice
125
+
126
+ chunk_size = 10
127
+ for i in range(0, len(extensions), chunk_size):
128
+ chunk = list(islice(extensions, i, i + chunk_size))
129
+ print(f" {' '.join(chunk)}")
126
130
  print(f"Total {len(extensions)} extensions supported")
127
131
 
128
132
  def output_json(self, data: Any) -> None:
@@ -6,7 +6,7 @@ Intelligent detection of project root directories based on common project marker
6
6
  """
7
7
 
8
8
  import logging
9
- import os
9
+ from pathlib import Path
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -93,13 +93,13 @@ class ProjectRootDetector:
93
93
 
94
94
  try:
95
95
  # Convert to absolute path and get directory
96
- abs_path = os.path.abspath(file_path)
97
- if os.path.isfile(abs_path):
98
- start_dir = os.path.dirname(abs_path)
96
+ abs_path = Path(file_path).resolve()
97
+ if abs_path.is_file():
98
+ start_dir = abs_path.parent
99
99
  else:
100
100
  start_dir = abs_path
101
101
 
102
- return self._traverse_upward(start_dir)
102
+ return self._traverse_upward(str(start_dir))
103
103
 
104
104
  except Exception as e:
105
105
  logger.warning(f"Error detecting project root from {file_path}: {e}")
@@ -113,7 +113,7 @@ class ProjectRootDetector:
113
113
  Project root directory path, or None if not detected
114
114
  """
115
115
  try:
116
- return self._traverse_upward(os.getcwd())
116
+ return self._traverse_upward(str(Path.cwd()))
117
117
  except Exception as e:
118
118
  logger.warning(f"Error detecting project root from cwd: {e}")
119
119
  return None
@@ -128,7 +128,7 @@ class ProjectRootDetector:
128
128
  Returns:
129
129
  Project root directory path, or None if not found
130
130
  """
131
- current_dir = os.path.abspath(start_dir)
131
+ current_dir = str(Path(start_dir).resolve())
132
132
  candidates = []
133
133
 
134
134
  for _depth in range(self.max_depth):
@@ -159,10 +159,11 @@ class ProjectRootDetector:
159
159
  return current_dir
160
160
 
161
161
  # Move up one directory
162
- parent_dir = os.path.dirname(current_dir)
163
- if parent_dir == current_dir: # Reached filesystem root
162
+ current_path = Path(current_dir)
163
+ parent_path = current_path.parent
164
+ if parent_path == current_path: # Reached filesystem root
164
165
  break
165
- current_dir = parent_dir
166
+ current_dir = str(parent_path)
166
167
 
167
168
  # Return the best candidate if any found
168
169
  if candidates:
@@ -190,19 +191,16 @@ class ProjectRootDetector:
190
191
  found_markers = []
191
192
 
192
193
  try:
193
- dir_contents = os.listdir(directory)
194
+ dir_path = Path(directory)
194
195
 
195
196
  for marker in PROJECT_MARKERS:
196
197
  if "*" in marker:
197
- # Handle glob patterns
198
- import glob
199
-
200
- pattern = os.path.join(directory, marker)
201
- if glob.glob(pattern):
198
+ # Handle glob patterns using pathlib
199
+ if list(dir_path.glob(marker)):
202
200
  found_markers.append(marker)
203
201
  else:
204
202
  # Handle exact matches
205
- if marker in dir_contents:
203
+ if (dir_path / marker).exists():
206
204
  found_markers.append(marker)
207
205
 
208
206
  except (OSError, PermissionError) as e:
@@ -258,15 +256,16 @@ class ProjectRootDetector:
258
256
  Fallback directory (file's directory or cwd)
259
257
  """
260
258
  try:
261
- if file_path and os.path.exists(file_path):
262
- if os.path.isfile(file_path):
263
- return os.path.dirname(os.path.abspath(file_path))
264
- else:
265
- return os.path.abspath(file_path)
266
- else:
267
- return os.getcwd()
259
+ if file_path:
260
+ path = Path(file_path)
261
+ if path.exists():
262
+ if path.is_file():
263
+ return str(path.resolve().parent)
264
+ else:
265
+ return str(path.resolve())
266
+ return str(Path.cwd())
268
267
  except Exception:
269
- return os.getcwd()
268
+ return str(Path.cwd())
270
269
 
271
270
 
272
271
  def detect_project_root(
@@ -292,9 +291,10 @@ def detect_project_root(
292
291
 
293
292
  # Priority 1: Explicit root
294
293
  if explicit_root:
295
- if os.path.exists(explicit_root) and os.path.isdir(explicit_root):
294
+ explicit_path = Path(explicit_root)
295
+ if explicit_path.exists() and explicit_path.is_dir():
296
296
  logger.debug(f"Using explicit project root: {explicit_root}")
297
- return os.path.abspath(explicit_root)
297
+ return str(explicit_path.resolve())
298
298
  else:
299
299
  logger.warning(f"Explicit project root does not exist: {explicit_root}")
300
300
 
@@ -6,7 +6,6 @@ Provides strict project boundary control to prevent access to files
6
6
  outside the designated project directory.
7
7
  """
8
8
 
9
- import os
10
9
  from pathlib import Path
11
10
 
12
11
  from ..exceptions import SecurityError
@@ -40,14 +39,15 @@ class ProjectBoundaryManager:
40
39
  if not project_root:
41
40
  raise SecurityError("Project root cannot be empty")
42
41
 
43
- if not os.path.exists(project_root):
42
+ project_path = Path(project_root)
43
+ if not project_path.exists():
44
44
  raise SecurityError(f"Project root does not exist: {project_root}")
45
45
 
46
- if not os.path.isdir(project_root):
46
+ if not project_path.is_dir():
47
47
  raise SecurityError(f"Project root is not a directory: {project_root}")
48
48
 
49
49
  # Store real path to prevent symlink attacks
50
- self.project_root = os.path.realpath(project_root)
50
+ self.project_root = str(project_path.resolve())
51
51
  self.allowed_directories: set[str] = {self.project_root}
52
52
 
53
53
  log_debug(f"ProjectBoundaryManager initialized with root: {self.project_root}")
@@ -65,13 +65,14 @@ class ProjectBoundaryManager:
65
65
  if not directory:
66
66
  raise SecurityError("Directory cannot be empty")
67
67
 
68
- if not os.path.exists(directory):
68
+ dir_path = Path(directory)
69
+ if not dir_path.exists():
69
70
  raise SecurityError(f"Directory does not exist: {directory}")
70
71
 
71
- if not os.path.isdir(directory):
72
+ if not dir_path.is_dir():
72
73
  raise SecurityError(f"Path is not a directory: {directory}")
73
74
 
74
- real_dir = os.path.realpath(directory)
75
+ real_dir = str(dir_path.resolve())
75
76
  self.allowed_directories.add(real_dir)
76
77
 
77
78
  log_info(f"Added allowed directory: {real_dir}")
@@ -92,16 +93,18 @@ class ProjectBoundaryManager:
92
93
  return False
93
94
 
94
95
  # Resolve real path to handle symlinks
95
- real_path = os.path.realpath(file_path)
96
+ real_path = str(Path(file_path).resolve())
96
97
 
97
98
  # Check against all allowed directories
98
99
  for allowed_dir in self.allowed_directories:
99
- if (
100
- real_path.startswith(allowed_dir + os.sep)
101
- or real_path == allowed_dir
102
- ):
100
+ # Use pathlib to check if path is within allowed directory
101
+ try:
102
+ Path(real_path).relative_to(Path(allowed_dir))
103
103
  log_debug(f"File path within boundaries: {file_path}")
104
104
  return True
105
+ except ValueError:
106
+ # Path is not within this allowed directory, continue checking
107
+ continue
105
108
 
106
109
  log_warning(f"File path outside boundaries: {file_path} -> {real_path}")
107
110
  return False
@@ -124,8 +127,14 @@ class ProjectBoundaryManager:
124
127
  return None
125
128
 
126
129
  try:
127
- real_path = os.path.realpath(file_path)
128
- rel_path = os.path.relpath(real_path, self.project_root)
130
+ real_path = Path(file_path).resolve()
131
+ try:
132
+ rel_path = real_path.relative_to(Path(self.project_root))
133
+ rel_path = str(rel_path)
134
+ except ValueError:
135
+ # Path is not relative to project root
136
+ log_warning(f"Path not relative to project root: {file_path}")
137
+ return None
129
138
 
130
139
  # Ensure relative path doesn't start with ..
131
140
  if rel_path.startswith(".."):
@@ -150,17 +159,18 @@ class ProjectBoundaryManager:
150
159
  """
151
160
  try:
152
161
  # Handle relative paths from project root
153
- if not os.path.isabs(file_path):
154
- full_path = os.path.join(self.project_root, file_path)
162
+ file_path_obj = Path(file_path)
163
+ if not file_path_obj.is_absolute():
164
+ full_path = Path(self.project_root) / file_path
155
165
  else:
156
- full_path = file_path
166
+ full_path = file_path_obj
157
167
 
158
168
  # Check boundaries
159
- if not self.is_within_project(full_path):
169
+ if not self.is_within_project(str(full_path)):
160
170
  return None
161
171
 
162
172
  # Return real path
163
- return os.path.realpath(full_path)
173
+ return str(full_path.resolve())
164
174
 
165
175
  except Exception as e:
166
176
  log_warning(f"Path validation error: {e}")
@@ -186,28 +196,27 @@ class ProjectBoundaryManager:
186
196
  True if path is safe from symlink attacks
187
197
  """
188
198
  try:
189
- if not os.path.exists(file_path):
199
+ file_path_obj = Path(file_path)
200
+ if not file_path_obj.exists():
190
201
  return True # Non-existent files are safe
191
202
 
192
203
  # If the fully resolved path is within project boundaries, we treat it as safe.
193
204
  # This makes the check tolerant to system-level symlinks like
194
205
  # /var -> /private/var on macOS runners.
195
- resolved = os.path.realpath(file_path)
206
+ resolved = str(file_path_obj.resolve())
196
207
  if self.is_within_project(resolved):
197
208
  return True
198
209
 
199
210
  # Otherwise, inspect each path component symlink to ensure no hop jumps outside
200
211
  # the allowed directories.
201
- path_parts = Path(file_path).parts
202
- current_path = ""
212
+ path_parts = file_path_obj.parts
213
+ current_path = Path()
203
214
 
204
215
  for part in path_parts:
205
- current_path = (
206
- os.path.join(current_path, part) if current_path else part
207
- )
216
+ current_path = current_path / part if current_path.parts else Path(part)
208
217
 
209
- if os.path.islink(current_path):
210
- target = os.path.realpath(current_path)
218
+ if current_path.is_symlink():
219
+ target = str(current_path.resolve())
211
220
  if not self.is_within_project(target):
212
221
  log_warning(
213
222
  f"Unsafe symlink detected: {current_path} -> {target}"