fix(sandbox): make glob patterns root-relative with recursive ** (#6046)

path_matches backs the glob tool and grep's glob= filter for every sandbox provider. It relied on PurePosixPath.match, which matches from the right and treats ** as a single-segment * on Python 3.12+, so only a leading **/ was recursive: src/**/*.py missed src/top.py and anything two or more levels deep, and docs/*.md also matched vendor/docs/b.md.

Match patterns that contain a / segment by segment from the search root, with ** spanning zero or more directories. Patterns without / keep matching the basename at any depth.
This commit is contained in:
Bob 2026-09-29 21:50:59 +08:00 committed by GitHub
parent f64cc16385
commit 9b95f64e45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 106 additions and 6 deletions

View File

@ -90,13 +90,32 @@ def should_ignore_path(path: str) -> bool:
return any(should_ignore_name(segment) for segment in path.replace("\\", "/").split("/") if segment)
def _match_segments(pattern_parts: tuple[str, ...], path_parts: tuple[str, ...]) -> bool:
# Positions in ``path_parts`` reachable after consuming each pattern part;
# ``**`` spans zero or more whole segments, any other part exactly one.
reachable = {0}
for part in pattern_parts:
if part == "**":
reachable = set(range(min(reachable), len(path_parts) + 1))
else:
reachable = {i + 1 for i in reachable if i < len(path_parts) and fnmatch.fnmatchcase(path_parts[i], part)}
if not reachable:
return False
return len(path_parts) in reachable
def path_matches(pattern: str, rel_path: str) -> bool:
path = PurePosixPath(rel_path)
if path.match(pattern):
return True
if pattern.startswith("**/"):
return path.match(pattern[3:])
return False
"""Match ``rel_path`` (relative to the search root) against a glob pattern.
A pattern without ``/`` matches the basename at any depth (``*.py``). Any
other pattern is anchored at the search root, and ``**`` matches zero or
more directories (``src/**/*.py`` includes ``src/top.py``).
``PurePosixPath.match`` is not used for these: it matches from the right
and treats ``**`` as a single-segment ``*``.
"""
if "/" not in pattern:
return PurePosixPath(rel_path).match(pattern)
return _match_segments(PurePosixPath(pattern).parts, PurePosixPath(rel_path).parts)
def truncate_line(line: str, max_chars: int = DEFAULT_LINE_SUMMARY_LENGTH) -> str:

View File

@ -0,0 +1,81 @@
"""Root-relative glob semantics of ``deerflow.sandbox.search.path_matches``.
``path_matches`` backs the ``glob`` tool and the ``grep`` ``glob=`` filter for
every sandbox provider. It relied on ``PurePosixPath.match``, which matches
from the right and treats ``**`` as a single-segment ``*`` on Python 3.12+, so
only a leading ``**/`` behaved recursively:
- ``src/**/*.py`` missed ``src/top.py`` and anything two or more levels deep;
- ``docs/*.md`` also matched ``vendor/docs/b.md`` although the pattern is
documented as relative to the search root.
"""
from pathlib import Path
import pytest
from deerflow.sandbox.search import find_glob_matches, find_grep_matches, path_matches
TREE = (
"src/top.py",
"src/pkg/mid.py",
"src/pkg/sub/deep.py",
"docs/a.md",
"vendor/docs/b.md",
)
@pytest.fixture
def tree(tmp_path: Path) -> Path:
for rel in TREE:
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("needle\n", encoding="utf-8")
return tmp_path
def _rel(paths: list[str], root: Path) -> set[str]:
return {Path(p).relative_to(root).as_posix() for p in paths}
@pytest.mark.parametrize(
("pattern", "expected"),
[
("src/**/*.py", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
("src/**", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
("src/**/sub/*.py", {"src/pkg/sub/deep.py"}),
("docs/*.md", {"docs/a.md"}),
("**/docs/*.md", {"docs/a.md", "vendor/docs/b.md"}),
# Unchanged behavior pinned elsewhere: a leading ``**/`` also matches
# at the root, and a pattern without "/" matches a basename anywhere.
("**/*.py", {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}),
("*.md", {"docs/a.md", "vendor/docs/b.md"}),
],
)
def test_glob_patterns_are_root_relative_and_recursive(tree: Path, pattern: str, expected: set[str]) -> None:
matches, truncated = find_glob_matches(tree, pattern)
assert _rel(matches, tree) == expected
assert truncated is False
def test_grep_glob_filter_uses_the_same_recursive_semantics(tree: Path) -> None:
matches, _ = find_grep_matches(tree, "needle", glob_pattern="src/**/*.py")
assert _rel([m.path for m in matches], tree) == {"src/top.py", "src/pkg/mid.py", "src/pkg/sub/deep.py"}
@pytest.mark.parametrize(
("pattern", "rel_path", "expected"),
[
("**", "a/b/c.txt", True),
("a/**/**/c.txt", "a/c.txt", True),
("a/*/c.txt", "a/b/x/c.txt", False),
("nested/*.txt", "nested/result.txt", True),
("nested/*.txt", "other/nested/result.txt", False),
("./nested/*.txt", "nested/result.txt", True),
("*.TXT", "a.txt", False),
],
)
def test_path_matches_segments(pattern: str, rel_path: str, expected: bool) -> None:
assert path_matches(pattern, rel_path) is expected