wcgw 3.0.6__py3-none-any.whl → 4.0.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 wcgw might be problematic. Click here for more details.

@@ -6,6 +6,7 @@ import threading
6
6
  import time
7
7
  import traceback
8
8
  from dataclasses import dataclass
9
+ from hashlib import sha256
9
10
  from typing import (
10
11
  Any,
11
12
  Literal,
@@ -232,11 +233,13 @@ class BashState:
232
233
  write_if_empty_mode: Optional[WriteIfEmptyMode],
233
234
  mode: Optional[Modes],
234
235
  use_screen: bool,
235
- whitelist_for_overwrite: Optional[set[str]] = None,
236
+ whitelist_for_overwrite: Optional[dict[str, "FileWhitelistData"]] = None,
236
237
  ) -> None:
237
238
  self._last_command: str = ""
238
239
  self.console = console
239
240
  self._cwd = working_dir or os.getcwd()
241
+ # Store the workspace root separately from the current working directory
242
+ self._workspace_root = working_dir or os.getcwd()
240
243
  self._bash_command_mode: BashCommandMode = bash_command_mode or BashCommandMode(
241
244
  "normal_mode", "all"
242
245
  )
@@ -245,7 +248,9 @@ class BashState:
245
248
  write_if_empty_mode or WriteIfEmptyMode("all")
246
249
  )
247
250
  self._mode = mode or "wcgw"
248
- self._whitelist_for_overwrite: set[str] = whitelist_for_overwrite or set()
251
+ self._whitelist_for_overwrite: dict[str, FileWhitelistData] = (
252
+ whitelist_for_overwrite or {}
253
+ )
249
254
  self._bg_expect_thread: Optional[threading.Thread] = None
250
255
  self._bg_expect_thread_stop_event = threading.Event()
251
256
  self._use_screen = use_screen
@@ -314,9 +319,11 @@ class BashState:
314
319
  self._bg_expect_thread_stop_event = threading.Event()
315
320
 
316
321
  def cleanup(self) -> None:
317
- self.close_bg_expect_thread()
318
- self._shell.close(True)
319
- cleanup_all_screens_with_name(self._shell_id, self.console)
322
+ try:
323
+ self.close_bg_expect_thread()
324
+ self._shell.close(True)
325
+ finally:
326
+ cleanup_all_screens_with_name(self._shell_id, self.console)
320
327
 
321
328
  def __enter__(self) -> "BashState":
322
329
  return self
@@ -427,6 +434,15 @@ class BashState:
427
434
  def cwd(self) -> str:
428
435
  return self._cwd
429
436
 
437
+ @property
438
+ def workspace_root(self) -> str:
439
+ """Return the workspace root directory."""
440
+ return self._workspace_root
441
+
442
+ def set_workspace_root(self, workspace_root: str) -> None:
443
+ """Set the workspace root directory."""
444
+ self._workspace_root = workspace_root
445
+
430
446
  @property
431
447
  def prompt(self) -> str:
432
448
  return PROMPT_CONST
@@ -454,20 +470,56 @@ class BashState:
454
470
  "bash_command_mode": self._bash_command_mode.serialize(),
455
471
  "file_edit_mode": self._file_edit_mode.serialize(),
456
472
  "write_if_empty_mode": self._write_if_empty_mode.serialize(),
457
- "whitelist_for_overwrite": list(self._whitelist_for_overwrite),
473
+ "whitelist_for_overwrite": {
474
+ k: v.serialize() for k, v in self._whitelist_for_overwrite.items()
475
+ },
458
476
  "mode": self._mode,
477
+ "workspace_root": self._workspace_root,
459
478
  }
460
479
 
461
480
  @staticmethod
462
481
  def parse_state(
463
482
  state: dict[str, Any],
464
- ) -> tuple[BashCommandMode, FileEditMode, WriteIfEmptyMode, Modes, list[str]]:
483
+ ) -> tuple[
484
+ BashCommandMode,
485
+ FileEditMode,
486
+ WriteIfEmptyMode,
487
+ Modes,
488
+ dict[str, "FileWhitelistData"],
489
+ str,
490
+ ]:
491
+ whitelist_state = state["whitelist_for_overwrite"]
492
+ # Convert serialized whitelist data back to FileWhitelistData objects
493
+ whitelist_dict = {}
494
+ if isinstance(whitelist_state, dict):
495
+ for file_path, data in whitelist_state.items():
496
+ if isinstance(data, dict) and "file_hash" in data:
497
+ # New format
498
+ whitelist_dict[file_path] = FileWhitelistData.deserialize(data)
499
+ else:
500
+ # Legacy format (just a hash string)
501
+ # Try to get line count from file if it exists, otherwise use a large default
502
+ whitelist_dict[file_path] = FileWhitelistData(
503
+ file_hash=data if isinstance(data, str) else "",
504
+ line_ranges_read=[(1, 1000000)], # Assume entire file was read
505
+ total_lines=1000000,
506
+ )
507
+ else:
508
+ # Handle really old format if needed
509
+ whitelist_dict = {
510
+ k: FileWhitelistData(
511
+ file_hash="", line_ranges_read=[(1, 1000000)], total_lines=1000000
512
+ )
513
+ for k in whitelist_state
514
+ }
515
+
465
516
  return (
466
517
  BashCommandMode.deserialize(state["bash_command_mode"]),
467
518
  FileEditMode.deserialize(state["file_edit_mode"]),
468
519
  WriteIfEmptyMode.deserialize(state["write_if_empty_mode"]),
469
520
  state["mode"],
470
- state["whitelist_for_overwrite"],
521
+ whitelist_dict,
522
+ state.get("workspace_root", ""),
471
523
  )
472
524
 
473
525
  def load_state(
@@ -476,15 +528,17 @@ class BashState:
476
528
  file_edit_mode: FileEditMode,
477
529
  write_if_empty_mode: WriteIfEmptyMode,
478
530
  mode: Modes,
479
- whitelist_for_overwrite: list[str],
531
+ whitelist_for_overwrite: dict[str, "FileWhitelistData"],
480
532
  cwd: str,
533
+ workspace_root: str,
481
534
  ) -> None:
482
535
  """Create a new BashState instance from a serialized state dictionary"""
483
536
  self._bash_command_mode = bash_command_mode
484
537
  self._cwd = cwd or self._cwd
538
+ self._workspace_root = workspace_root or cwd or self._workspace_root
485
539
  self._file_edit_mode = file_edit_mode
486
540
  self._write_if_empty_mode = write_if_empty_mode
487
- self._whitelist_for_overwrite = set(whitelist_for_overwrite)
541
+ self._whitelist_for_overwrite = dict(whitelist_for_overwrite)
488
542
  self._mode = mode
489
543
  self.reset_shell()
490
544
 
@@ -505,17 +559,132 @@ class BashState:
505
559
  return "Not pending"
506
560
 
507
561
  @property
508
- def whitelist_for_overwrite(self) -> set[str]:
562
+ def whitelist_for_overwrite(self) -> dict[str, "FileWhitelistData"]:
509
563
  return self._whitelist_for_overwrite
510
564
 
511
- def add_to_whitelist_for_overwrite(self, file_path: str) -> None:
512
- self._whitelist_for_overwrite.add(file_path)
565
+ def add_to_whitelist_for_overwrite(
566
+ self, file_paths_with_ranges: dict[str, list[tuple[int, int]]]
567
+ ) -> None:
568
+ """
569
+ Add files to the whitelist for overwrite.
570
+
571
+ Args:
572
+ file_paths_with_ranges: Dictionary mapping file paths to sequences of
573
+ (start_line, end_line) tuples representing
574
+ the ranges that have been read.
575
+ """
576
+ for file_path, ranges in file_paths_with_ranges.items():
577
+ # Read the file to get its hash and count lines
578
+ with open(file_path, "rb") as f:
579
+ file_content = f.read()
580
+ file_hash = sha256(file_content).hexdigest()
581
+ total_lines = file_content.count(b"\n") + 1
582
+
583
+ # Update or create whitelist entry
584
+ if file_path in self._whitelist_for_overwrite:
585
+ # Update existing entry
586
+ whitelist_data = self._whitelist_for_overwrite[file_path]
587
+ whitelist_data.file_hash = file_hash
588
+ whitelist_data.total_lines = total_lines
589
+ for range_start, range_end in ranges:
590
+ whitelist_data.add_range(range_start, range_end)
591
+ else:
592
+ # Create new entry
593
+ self._whitelist_for_overwrite[file_path] = FileWhitelistData(
594
+ file_hash=file_hash,
595
+ line_ranges_read=list(ranges),
596
+ total_lines=total_lines,
597
+ )
513
598
 
514
599
  @property
515
600
  def pending_output(self) -> str:
516
601
  return self._pending_output
517
602
 
518
603
 
604
+ @dataclass
605
+ class FileWhitelistData:
606
+ """Data about a file that has been read and can be modified."""
607
+
608
+ file_hash: str
609
+ # List of line ranges that have been read (inclusive start, inclusive end)
610
+ # E.g., [(1, 10), (20, 30)] means lines 1-10 and 20-30 have been read
611
+ line_ranges_read: list[tuple[int, int]]
612
+ # Total number of lines in the file
613
+ total_lines: int
614
+
615
+ def get_percentage_read(self) -> float:
616
+ """Calculate percentage of file read based on line ranges."""
617
+ if self.total_lines == 0:
618
+ return 100.0
619
+
620
+ # Count unique lines read
621
+ lines_read: set[int] = set()
622
+ for start, end in self.line_ranges_read:
623
+ lines_read.update(range(start, end + 1))
624
+
625
+ return (len(lines_read) / self.total_lines) * 100.0
626
+
627
+ def is_read_enough(self) -> bool:
628
+ """Check if enough of the file has been read (>=99%)"""
629
+ return self.get_percentage_read() >= 99
630
+
631
+ def get_unread_ranges(self) -> list[tuple[int, int]]:
632
+ """Return a list of line ranges (start, end) that haven't been read yet.
633
+
634
+ Returns line ranges as tuples of (start_line, end_line) in 1-indexed format.
635
+ If the whole file has been read, returns an empty list.
636
+ """
637
+ if self.total_lines == 0:
638
+ return []
639
+
640
+ # First collect all lines that have been read
641
+ lines_read: set[int] = set()
642
+ for start, end in self.line_ranges_read:
643
+ lines_read.update(range(start, end + 1))
644
+
645
+ # Generate unread ranges from the gaps
646
+ unread_ranges: list[tuple[int, int]] = []
647
+ start_range = None
648
+
649
+ for i in range(1, self.total_lines + 1):
650
+ if i not in lines_read:
651
+ if start_range is None:
652
+ start_range = i
653
+ elif start_range is not None:
654
+ # End of an unread range
655
+ unread_ranges.append((start_range, i - 1))
656
+ start_range = None
657
+
658
+ # Don't forget the last range if it extends to the end of the file
659
+ if start_range is not None:
660
+ unread_ranges.append((start_range, self.total_lines))
661
+
662
+ return unread_ranges
663
+
664
+ def add_range(self, start: int, end: int) -> None:
665
+ """Add a new range of lines that have been read."""
666
+ # Merge with existing ranges if possible
667
+ self.line_ranges_read.append((start, end))
668
+ # Could add range merging logic here for optimization
669
+
670
+ def serialize(self) -> dict[str, Any]:
671
+ """Convert to a serializable dictionary."""
672
+ return {
673
+ "file_hash": self.file_hash,
674
+ "line_ranges_read": self.line_ranges_read,
675
+ "total_lines": self.total_lines,
676
+ }
677
+
678
+ @classmethod
679
+ def deserialize(cls, data: dict[str, Any]) -> "FileWhitelistData":
680
+ """Create from a serialized dictionary."""
681
+ return cls(
682
+ file_hash=data.get("file_hash", ""),
683
+ line_ranges_read=data.get("line_ranges_read", []),
684
+ total_lines=data.get("total_lines", 0),
685
+ )
686
+
687
+
519
688
  WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
520
689
  1. Get its output using status check.
521
690
  2. Use `send_ascii` or `send_specials` to give inputs to the running program OR
@@ -1,17 +1,40 @@
1
1
 
2
2
  Instructions for editing files.
3
+ # Example
4
+ ## Input file
5
+ ```
6
+ import numpy as np
7
+ from impls import impl1, impl2
8
+
9
+ def hello():
10
+ "print a greeting"
3
11
 
12
+ print("hello")
4
13
 
5
- Only edit the files using the following SEARCH/REPLACE blocks.
14
+ def call_hello():
15
+ "call hello"
16
+
17
+ hello()
18
+ print("Called")
19
+ impl1()
20
+ hello()
21
+ impl2()
22
+
23
+ ```
24
+ ## Edit format on the input file
6
25
  ```
7
- file_edit_using_search_replace_blocks="""
26
+ <<<<<<< SEARCH
27
+ from impls import impl1, impl2
28
+ =======
29
+ from impls import impl1, impl2
30
+ from hello import hello as hello_renamed
31
+ >>>>>>> REPLACE
8
32
  <<<<<<< SEARCH
9
33
  def hello():
10
34
  "print a greeting"
11
35
 
12
36
  print("hello")
13
37
  =======
14
- from hello import hello as hello_renamed
15
38
  >>>>>>> REPLACE
16
39
  <<<<<<< SEARCH
17
40
  def call_hello():
@@ -33,26 +56,17 @@ def call_hello_renamed():
33
56
  hello_renamed()
34
57
  impl2()
35
58
  >>>>>>> REPLACE
36
- """
37
59
  ```
38
60
 
39
61
  # *SEARCH/REPLACE block* Rules:
40
-
41
- Every *SEARCH/REPLACE block* must use this format:
42
- 1. The start of match block: <<<<<<< SEARCH
43
- 2. A contiguous chunk of lines to do exact match for in the existing source code
44
- 3. The dividing line: =======
45
- 4. The lines to replace into the source code
46
- 5. The end of the replace block: >>>>>>> REPLACE
47
-
48
62
  Every "<<<<<<< SEARCH" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc.
49
63
 
50
64
  Including multiple unique *SEARCH/REPLACE* blocks if needed.
51
- Include enough lines in each SEARCH section to uniquely match each set of lines that need to change.
65
+ Include enough and only enough lines in each SEARCH section to uniquely match each set of lines that need to change.
52
66
 
53
67
  Keep *SEARCH/REPLACE* blocks concise.
54
68
  Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file.
55
- Include just the changing lines, and a few surrounding lines if needed for uniqueness.
56
- Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks.
69
+ Include just the changing lines, and a few surrounding lines (2-3 lines) if needed for uniqueness.
70
+ Other than for uniqueness, avoid including those lines which do not change in search (and replace) blocks. Target 2-3 non trivial extra lines per block.
57
71
 
58
72
  Preserve leading spaces and indentations in both SEARCH and REPLACE blocks.
@@ -45,11 +45,12 @@ class FileEditOutput:
45
45
  if tol.severity_cat == "WARNING":
46
46
  warnings.add(tol.error_name)
47
47
  elif tol.severity_cat == "ERROR":
48
+ search__ = "\n".join(search_)
48
49
  errors.append(f"""
49
50
  Got error while processing the following search block:
50
51
  ---
51
52
  ```
52
- {"\n".join(search_)}
53
+ {search__}
53
54
  ```
54
55
  ---
55
56
  Error:
@@ -5,67 +5,79 @@ from .diff_edit import FileEditInput, FileEditOutput, SearchReplaceMatchError
5
5
 
6
6
  # Global regex patterns
7
7
  SEARCH_MARKER = re.compile(r"^<<<<<<+\s*SEARCH\s*$")
8
- DIVIDER_MARKER = re.compile(r"^======*\s*$")
8
+ DIVIDER_MARKER = re.compile(r"^======*\s*$")
9
9
  REPLACE_MARKER = re.compile(r"^>>>>>>+\s*REPLACE\s*$")
10
10
 
11
+
11
12
  class SearchReplaceSyntaxError(Exception):
12
13
  def __init__(self, message: str):
13
- message =f"""Got syntax error while parsing search replace blocks:
14
+ message = f"""Got syntax error while parsing search replace blocks:
14
15
  {message}
15
16
  ---
16
17
 
17
18
  Make sure blocks are in correct sequence, and the markers are in separate lines:
18
19
 
19
- <{'<<<<<< SEARCH'}
20
+ <{"<<<<<< SEARCH"}
20
21
  example old
21
22
  =======
22
23
  example new
23
- >{'>>>>>> REPLACE'}
24
+ >{">>>>>> REPLACE"}
24
25
 
25
26
  """
26
27
  super().__init__(message)
27
28
 
29
+
28
30
  def search_replace_edit(
29
31
  lines: list[str], original_content: str, logger: Callable[[str], object]
30
32
  ) -> tuple[str, str]:
31
33
  if not lines:
32
34
  raise SearchReplaceSyntaxError("Error: No input to search replace edit")
33
-
35
+
34
36
  original_lines = original_content.split("\n")
35
37
  n_lines = len(lines)
36
38
  i = 0
37
39
  search_replace_blocks = list[tuple[list[str], list[str]]]()
38
-
40
+
39
41
  while i < n_lines:
40
42
  if SEARCH_MARKER.match(lines[i]):
41
43
  line_num = i + 1
42
44
  search_block = []
43
45
  i += 1
44
-
46
+
45
47
  while i < n_lines and not DIVIDER_MARKER.match(lines[i]):
46
48
  if SEARCH_MARKER.match(lines[i]) or REPLACE_MARKER.match(lines[i]):
47
- raise SearchReplaceSyntaxError(f"Line {i+1}: Found stray marker in SEARCH block: {lines[i]}")
49
+ raise SearchReplaceSyntaxError(
50
+ f"Line {i + 1}: Found stray marker in SEARCH block: {lines[i]}"
51
+ )
48
52
  search_block.append(lines[i])
49
53
  i += 1
50
-
54
+
51
55
  if i >= n_lines:
52
- raise SearchReplaceSyntaxError(f"Line {line_num}: Unclosed SEARCH block - missing ======= marker")
53
-
56
+ raise SearchReplaceSyntaxError(
57
+ f"Line {line_num}: Unclosed SEARCH block - missing ======= marker"
58
+ )
59
+
54
60
  if not search_block:
55
- raise SearchReplaceSyntaxError(f"Line {line_num}: SEARCH block cannot be empty")
56
-
61
+ raise SearchReplaceSyntaxError(
62
+ f"Line {line_num}: SEARCH block cannot be empty"
63
+ )
64
+
57
65
  i += 1
58
66
  replace_block = []
59
-
67
+
60
68
  while i < n_lines and not REPLACE_MARKER.match(lines[i]):
61
69
  if SEARCH_MARKER.match(lines[i]) or DIVIDER_MARKER.match(lines[i]):
62
- raise SearchReplaceSyntaxError(f"Line {i+1}: Found stray marker in REPLACE block: {lines[i]}")
70
+ raise SearchReplaceSyntaxError(
71
+ f"Line {i + 1}: Found stray marker in REPLACE block: {lines[i]}"
72
+ )
63
73
  replace_block.append(lines[i])
64
74
  i += 1
65
-
75
+
66
76
  if i >= n_lines:
67
- raise SearchReplaceSyntaxError(f"Line {line_num}: Unclosed block - missing REPLACE marker")
68
-
77
+ raise SearchReplaceSyntaxError(
78
+ f"Line {line_num}: Unclosed block - missing REPLACE marker"
79
+ )
80
+
69
81
  i += 1
70
82
 
71
83
  for line in search_block:
@@ -78,7 +90,9 @@ def search_replace_edit(
78
90
  search_replace_blocks.append((search_block, replace_block))
79
91
  else:
80
92
  if REPLACE_MARKER.match(lines[i]) or DIVIDER_MARKER.match(lines[i]):
81
- raise SearchReplaceSyntaxError(f"Line {i+1}: Found stray marker outside block: {lines[i]}")
93
+ raise SearchReplaceSyntaxError(
94
+ f"Line {i + 1}: Found stray marker outside block: {lines[i]}"
95
+ )
82
96
  i += 1
83
97
 
84
98
  if not search_replace_blocks:
@@ -121,11 +135,12 @@ def greedy_context_replace(
121
135
  if len(best_matches) > 1:
122
136
  # Duplicate found, try to ground using previous blocks.
123
137
  if current_block_offset == 0:
138
+ matches_ = "\n".join(current_blocks[-1][0])
124
139
  raise SearchReplaceMatchError(f"""
125
140
  The following block matched more than once:
126
141
  ---
127
142
  ```
128
- {'\n'.join(current_blocks[-1][0])}
143
+ {matches_}
129
144
  ```
130
145
  """)
131
146
 
@@ -140,11 +155,12 @@ def greedy_context_replace(
140
155
  original_lines, search_replace_blocks, original_lines, set(), 0
141
156
  )
142
157
  except Exception:
158
+ ma_more = "\n".join(current_blocks[-1][0])
143
159
  raise Exception(f"""
144
160
  The following block matched more than once:
145
161
  ---
146
162
  ```
147
- {'\n'.join(current_blocks[-1][0])}
163
+ {ma_more}
148
164
  ```
149
165
  """)
150
166
 
wcgw/client/memory.py CHANGED
@@ -2,7 +2,7 @@ import json
2
2
  import os
3
3
  import re
4
4
  import shlex
5
- from typing import Any, Callable, Optional
5
+ from typing import Any, Callable, Optional, TypeVar
6
6
 
7
7
  from ..types_ import ContextSave
8
8
 
@@ -59,7 +59,10 @@ def save_memory(
59
59
  return memory_file_full
60
60
 
61
61
 
62
- def load_memory[T](
62
+ T = TypeVar("T")
63
+
64
+
65
+ def load_memory(
63
66
  task_id: str,
64
67
  max_tokens: Optional[int],
65
68
  encoder: Callable[[str], list[T]],
wcgw/client/modes.py CHANGED
@@ -55,38 +55,38 @@ You are now running in "code_writer" mode.
55
55
  """
56
56
 
57
57
  path_prompt = """
58
- - You are allowed to run FileEdit in the provided repository only.
58
+ - You are allowed to edit files in the provided repository only.
59
59
  """
60
60
 
61
61
  if allowed_file_edit_globs != "all":
62
62
  if allowed_file_edit_globs:
63
63
  path_prompt = f"""
64
- - You are allowed to run FileEdit for files matching only the following globs: {", ".join(allowed_file_edit_globs)}
64
+ - You are allowed to edit files for files matching only the following globs: {", ".join(allowed_file_edit_globs)}
65
65
  """
66
66
  else:
67
67
  path_prompt = """
68
- - You are not allowed to run FileEdit.
68
+ - You are not allowed to edit files.
69
69
  """
70
70
  base += path_prompt
71
71
 
72
72
  path_prompt = """
73
- - You are allowed to run WriteIfEmpty in the provided repository only.
73
+ - You are allowed to write files in the provided repository only.
74
74
  """
75
75
 
76
76
  if all_write_new_globs != "all":
77
77
  if all_write_new_globs:
78
78
  path_prompt = f"""
79
- - You are allowed to run WriteIfEmpty files matching only the following globs: {", ".join(allowed_file_edit_globs)}
79
+ - You are allowed to write files files matching only the following globs: {", ".join(allowed_file_edit_globs)}
80
80
  """
81
81
  else:
82
82
  path_prompt = """
83
- - You are not allowed to run WriteIfEmpty.
83
+ - You are not allowed to write files.
84
84
  """
85
85
  base += path_prompt
86
86
 
87
87
  run_command_common = """
88
88
  - Do not use Ctrl-c interrupt commands without asking the user, because often the programs don't show any update but they still are running.
89
- - Do not use echo to write multi-line files, always use FileEdit tool to update a code.
89
+ - Do not use echo to write multi-line files, always use FileWriteOrEdit tool to update a code.
90
90
  - Do not provide code snippets unless asked by the user, instead directly add/edit the code.
91
91
  - You should use the provided bash execution, reading and writing file tools to complete objective.
92
92
  - First understand about the project by getting the folder structure (ignoring .git, node_modules, venv, etc.)
@@ -33,13 +33,13 @@ class DirectoryTree:
33
33
  abs_path = self.root / rel_path
34
34
 
35
35
  if not abs_path.exists():
36
- raise ValueError(f"Path {rel_path} does not exist")
36
+ return
37
37
 
38
38
  if not abs_path.is_file():
39
- raise ValueError(f"Path {rel_path} is not a file")
39
+ return
40
40
 
41
41
  if not str(abs_path).startswith(str(self.root)):
42
- raise ValueError(f"Path {rel_path} is outside root directory")
42
+ return
43
43
 
44
44
  self.expanded_files.add(abs_path)
45
45