match() treats paths wrongly

Done

/home/devdi/devswarm/evals/tasks/hard_path_glob/repo · 2026-11-09 17:14:17 UTC · took 1m 47s

  1. Plan (done)
  2. Code (done)
  3. Test (done)
  4. Review (done)
  5. Finish (done)
Cost
$0.0150
Tests
Passed
Iterations
2
Re-plans
0
LLM calls
19
Prompt read from cache
75%

Usage by agent

Cost per agent
planner: $0.0017 planner planner, Cost: $0.0017 $0.0017 coder: $0.0061 coder coder, Cost: $0.0061 $0.0061 tester: $0.0072 tester tester, Cost: $0.0072 $0.0072
Tokens per agent
  • Cache read
  • Cache write
  • Input
  • Output
planner: 9,762 planner planner, Cache read: 4,441 planner, Cache write: 2,654 planner, Output: 2,659 9,762 coder: 58.2K coder coder, Cache read: 35.8K coder, Cache write: 14.5K coder, Output: 7,870 58.2K tester: 92.6K tester tester, Cache read: 66.5K tester, Cache write: 17.4K tester, Output: 8,657 92.6K
View as table
AgentCostCache readCache writeInputOutput
planner$0.00174,4412,65482,659
coder$0.006135,79314,548187,870
tester$0.007266,49717,428228,657

Timeline

  1. planner iteration 1 Finished 3 calls · 9,762 tokens · $0.0017 · 13.9s

    Loading…

  2. coder iteration 1 Finished 4 calls · 31.7K tokens · $0.0040 · 27.1s

    Loading…

  3. tester iteration 1 Finished 6 calls · 65.0K tokens · $0.0044 · 32.5s

    Loading…

  4. verify iteration 1 Failed 0.5s
    .............F.....                                                      [100%]
    =================================== FAILURES ===================================
    ________________ test_trailing_globstar_matches_newline_in_name ________________
    
        def test_trailing_globstar_matches_newline_in_name():
            # '*' and '?' match any character except '/', including newline;
            # '/**' should be consistent with that.
            assert match("src/*", "src/a\nb")
    >       assert match("src/**", "src/a\nb")
    E       AssertionError: assert False
    E        +  where False = match('src/**', 'src/a\nb')
    
    test_pathmatch.py:78: AssertionError
    =========================== short test summary info ============================
    FAILED test_pathmatch.py::test_trailing_globstar_matches_newline_in_name - As...
    1 failed, 18 passed in 0.06s
    
  5. coder iteration 2 Finished 3 calls · 26.5K tokens · $0.0021 · 13.4s

    Loading…

  6. tester iteration 2 Finished 3 calls · 27.6K tokens · $0.0028 · 19.1s

    Loading…

  7. verify iteration 2 Passed 0.6s
    ...................                                                      [100%]
    19 passed in 0.05s
    

Final diff

diff --git a/pathmatch.py b/pathmatch.py
index 42f6c2a..e25414b 100644
--- a/pathmatch.py
+++ b/pathmatch.py
@@ -1,5 +1,58 @@
-import fnmatch
+import functools
+import re
+
+
+def _bracket_class(pattern: str, i: int):
+    """Translate a bracket expression starting at pattern[i] == '['.
+
+    Returns (regex_fragment, next_index). If the bracket is not closed,
+    the '[' is treated as a literal character.
+    """
+    negated = pattern.startswith("!", i + 1)
+    start = i + 2 if negated else i + 1
+    end = pattern.find("]", start + 1)
+    if end == -1:
+        return re.escape("["), i + 1
+    content = "".join(re.escape(c) for c in pattern[start:end])
+    if negated:
+        return "[^" + content + "/]", end + 1
+    return "[" + content + "]", end + 1
+
+
+def _glob_to_regex(pattern: str) -> str:
+    parts = []
+    i = 0
+    n = len(pattern)
+    while i < n:
+        c = pattern[i]
+        if c == "*" and pattern.startswith("**/", i):
+            # zero or more whole directories
+            parts.append("(?:[^/]+/)*")
+            i += 3
+        elif c == "/" and pattern.startswith("/**", i) and i + 3 == n:
+            # trailing /** : everything below the directory, not the directory
+            parts.append("/.+")
+            i += 3
+        elif c == "*":
+            parts.append("[^/]*")
+            i += 1
+        elif c == "?":
+            parts.append("[^/]")
+            i += 1
+        elif c == "[":
+            fragment, i = _bracket_class(pattern, i)
+            parts.append(fragment)
+        else:
+            parts.append(re.escape(c))
+            i += 1
+    return "".join(parts)
+
+
+@functools.lru_cache(maxsize=1024)
+def _compile(pattern: str):
+    # DOTALL so that the '.' in '/.+' matches newlines, like '*' and '?' do.
+    return re.compile(_glob_to_regex(pattern), re.DOTALL)
 
 
 def match(pattern: str, path: str) -> bool:
-    return fnmatch.fnmatchcase(path, pattern)
+    return _compile(pattern).fullmatch(path) is not None
diff --git a/test_pathmatch.py b/test_pathmatch.py
index 1895e55..7867396 100644
--- a/test_pathmatch.py
+++ b/test_pathmatch.py
@@ -7,3 +7,106 @@ def test_star():
 
 def test_question():
     assert match("a?c", "abc")
+
+
+def test_star_does_not_cross_slash():
+    assert match("*.py", "a.py")
+    assert not match("*.py", "x/a.py")
+
+
+def test_question_does_not_match_slash():
+    assert not match("a?c", "a/c")
+    assert match("a?c", "abc")
+
+
+def test_leading_globstar():
+    assert match("**/a.py", "a.py")
+    assert match("**/a.py", "x/y/a.py")
+    assert not match("**/a.py", "x/b.py")
+
+
+def test_middle_globstar():
+    assert match("a/**/b", "a/b")
+    assert match("a/**/b", "a/x/y/b")
+    assert not match("a/**/b", "a/xb")
+
+
+def test_trailing_globstar():
+    assert match("src/**", "src/a")
+    assert match("src/**", "src/a/b")
+    assert not match("src/**", "src")
+
+
+def test_bracket_set():
+    assert match("[abc]x", "bx")
+    assert not match("[abc]x", "dx")
+
+
+def test_bracket_negated():
+    assert match("[!abc]x", "dx")
+    assert not match("[!abc]x", "ax")
+    assert not match("[!abc]x", "/x")
+
+
+def test_unclosed_bracket_is_literal():
+    assert match("[a", "[a")
+    assert not match("[a", "a")
+
+
+def test_regex_metacharacters_are_literal():
+    assert match("a.c", "a.c")
+    assert not match("a.c", "abc")
+    assert match("a+", "a+")
+    assert not match("a+", "aa")
+    assert match("(a)", "(a)")
+    assert not match("(a)", "a")
+
+
+def test_full_path_anchoring():
+    assert match("a.py", "a.py")
+    assert not match("a.py", "x/a.py")
+
+
+def test_case_sensitive():
+    assert not match("*.PY", "a.py")
+
+
+def test_trailing_globstar_matches_newline_in_name():
+    # '*' and '?' match any character except '/', including newline;
+    # '/**' should be consistent with that.
+    assert match("src/*", "src/a\nb")
+    assert match("src/**", "src/a\nb")
+
+
+def test_double_star_without_slash_acts_as_star():
+    assert match("a**b", "axb")
+    assert not match("a**b", "ax/b")
+
+
+def test_bare_double_star_does_not_cross_slash():
+    assert match("**", "ab")
+    assert not match("**", "a/b")
+
+
+def test_empty_and_odd_brackets_do_not_break_regex():
+    assert match("[]a]", "]")
+    assert match("[]a]", "a")
+    assert not match("[]a]", "b")
+    assert match("[!]x", "[!]x")
+    assert not match("[!]x", "zx")
+    assert not match("[!]x", "/x")
+
+
+def test_bracket_special_characters_are_literal():
+    assert match("[\\\\]", "\\")
+    assert match("[^a]", "^")
+    assert not match("[^a]", "b")
+    assert match("[a-c]", "-")
+    assert match("[a-c]", "a")
+    assert not match("[a-c]", "b")
+
+
+def test_unclosed_bracket_with_later_text():
+    assert match("a[", "a[")
+    assert match("[a]b", "ab")
+    assert not match("[a]b", "[a]b")