diff --git a/backend/packages/harness/deerflow/sandbox/search.py b/backend/packages/harness/deerflow/sandbox/search.py index a85938870..a5cf1314e 100644 --- a/backend/packages/harness/deerflow/sandbox/search.py +++ b/backend/packages/harness/deerflow/sandbox/search.py @@ -67,11 +67,22 @@ class GrepMatch: line: str +# ``should_ignore_name`` runs once per directory entry during glob/grep tree +# walks, so we avoid ~50 ``fnmatch`` calls per name. Most ignore patterns are +# literal names (O(1) set lookup after normcase); the few glob patterns are +# pre-translated into a single combined regex. ``os.path.normcase`` keeps the +# same case behavior ``fnmatch`` applies (case-sensitive on POSIX, folded on +# Windows). +_EXACT_IGNORE_NAMES = frozenset(os.path.normcase(p) for p in IGNORE_PATTERNS if not any(c in p for c in "*?[")) +_GLOB_IGNORE_PATTERNS = [p for p in IGNORE_PATTERNS if any(c in p for c in "*?[")] +_GLOB_IGNORE_RE = re.compile("|".join(fnmatch.translate(os.path.normcase(p)) for p in _GLOB_IGNORE_PATTERNS)) if _GLOB_IGNORE_PATTERNS else None + + def should_ignore_name(name: str) -> bool: - for pattern in IGNORE_PATTERNS: - if fnmatch.fnmatch(name, pattern): - return True - return False + normalized = os.path.normcase(name) + if normalized in _EXACT_IGNORE_NAMES: + return True + return _GLOB_IGNORE_RE is not None and _GLOB_IGNORE_RE.match(normalized) is not None def should_ignore_path(path: str) -> bool: diff --git a/backend/tests/test_should_ignore_name.py b/backend/tests/test_should_ignore_name.py new file mode 100644 index 000000000..4fddae874 --- /dev/null +++ b/backend/tests/test_should_ignore_name.py @@ -0,0 +1,72 @@ +"""should_ignore_name must stay behavior-identical to the original per-pattern +fnmatch loop while doing O(1) set lookup + one combined glob regex instead of +~50 fnmatch calls per directory entry. +""" + +from __future__ import annotations + +import fnmatch + +from deerflow.sandbox.search import IGNORE_PATTERNS, should_ignore_name, should_ignore_path + + +def _reference(name: str) -> bool: + """Original implementation, kept here as the equivalence oracle.""" + return any(fnmatch.fnmatch(name, pattern) for pattern in IGNORE_PATTERNS) + + +_SAMPLES = [ + # exact-name ignores + ".git", + "node_modules", + "__pycache__", + ".venv", + "env", + "logs", + "coverage", + ".pytest_cache", + ".DS_Store", + "Thumbs.db", + "desktop.ini", + # glob ignores + "thing.egg-info", + "x.swp", + "y.swo", + "z.log", + "a.tmp", + "b.temp", + "c.bak", + "d.cache", + "core~", + "shortcut.lnk", + # kept (must NOT be ignored) + "foo.py", + "README.md", + "src", + "myenv", + "node_modules_x", + "x.git", + "log", + "main.c", +] + + +def test_matches_reference_for_all_samples(): + for name in _SAMPLES: + assert should_ignore_name(name) == _reference(name), name + + +def test_known_ignored_names(): + for name in [".git", "node_modules", "__pycache__", "x.swp", "z.log", "core~", "thing.egg-info"]: + assert should_ignore_name(name) is True + + +def test_known_kept_names(): + for name in ["foo.py", "README.md", "src", "myenv", "node_modules_x", "x.git"]: + assert should_ignore_name(name) is False + + +def test_should_ignore_path_segments(): + assert should_ignore_path("a/node_modules/b") is True + assert should_ignore_path("proj/.git/config") is True + assert should_ignore_path("a/b/c.py") is False