diff --git a/backend/packages/harness/deerflow/sandbox/search.py b/backend/packages/harness/deerflow/sandbox/search.py index 83d7f39a83..d5da00be99 100644 --- a/backend/packages/harness/deerflow/sandbox/search.py +++ b/backend/packages/harness/deerflow/sandbox/search.py @@ -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: diff --git a/backend/tests/test_sandbox_glob_patterns.py b/backend/tests/test_sandbox_glob_patterns.py new file mode 100644 index 0000000000..097c9681b7 --- /dev/null +++ b/backend/tests/test_sandbox_glob_patterns.py @@ -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