match() treats paths wrongly

Done

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

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

Usage by agent

Cost per agent
planner: $0.0014 planner planner, Cost: $0.0014 $0.0014 coder: $0.0083 coder coder, Cost: $0.0083 $0.0083 tester: $0.0143 tester tester, Cost: $0.0143 $0.0143 reviewer: $0.0029 reviewer reviewer, Cost: $0.0029 $0.0029
Tokens per agent
  • Cache read
  • Cache write
  • Input
  • Output
planner: 9,581 planner planner, Cache read: 6,097 planner, Cache write: 992 planner, Output: 2,484 9,581 coder: 94.0K coder coder, Cache read: 60.7K coder, Cache write: 24.0K coder, Output: 9,312 94.0K tester: 187.2K tester tester, Cache read: 136.6K tester, Cache write: 33.1K tester, Output: 17.5K 187.2K reviewer: 20.4K reviewer reviewer, Cache read: 7,207 reviewer, Cache write: 10.1K reviewer, Output: 3,133 20.4K
View as table
AgentCostCache readCache writeInputOutput
planner$0.00146,09799282,484
coder$0.008360,65624,001269,312
tester$0.0143136,57933,1363817,479
reviewer$0.00297,20710,07463,133

Timeline

  1. planner iteration 1 Finished 3 calls · 9,581 tokens · $0.0014 · 12.9s

    Loading…

  2. coder iteration 1 Finished 4 calls · 30.7K tokens · $0.0035 · 21.2s

    Loading…

  3. tester iteration 1 Finished 4 calls · 37.7K tokens · $0.0035 · 28.0s

    Loading…

  4. verify iteration 1 Failed 0.6s
    .....................F                                                   [100%]
    =================================== FAILURES ===================================
    __________________ test_reversed_bracket_range_does_not_raise __________________
    
        def test_reversed_bracket_range_does_not_raise():
            # fnmatch treats an invalid range as a non-match; match() should not
            # leak re.error to callers.
    >       assert match("[z-a]x", "bx") is False
                   ^^^^^^^^^^^^^^^^^^^^^
    
    test_pathmatch.py:130: 
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    pathmatch.py:88: in match
        return _compile(pattern).fullmatch(path) is not None
               ^^^^^^^^^^^^^^^^^
    pathmatch.py:84: in _compile
        return re.compile(_translate(pattern), re.DOTALL)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    /usr/local/lib/python3.12/re/__init__.py:228: in compile
        return _compile(pattern, flags)
               ^^^^^^^^^^^^^^^^^^^^^^^^
    /usr/local/lib/python3.12/re/__init__.py:307: in _compile
        p = _compiler.compile(pattern, flags)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    /usr/local/lib/python3.12/re/_compiler.py:750: in compile
        p = _parser.parse(p, flags)
            ^^^^^^^^^^^^^^^^^^^^^^^
    /usr/local/lib/python3.12/re/_parser.py:979: in parse
        p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    /usr/local/lib/python3.12/re/_parser.py:460: in _parse_sub
        itemsappend(_parse(source, state, verbose, nested + 1,
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
    
    source = <re._parser.Tokenizer object at 0x77104476e900>
    state = <re._parser.State object at 0x77104476e4b0>, verbose = 0, nested = 1
    first = True
    
        def _parse(source, state, verbose, nested, first=False):
            # parse a simple pattern
            subpattern = SubPattern(state)
        
            # precompute constants into local variables
            subpatternappend = subpattern.append
            sourceget = source.get
            sourcematch = source.match
            _len = len
            _ord = ord
        
            while True:
        
                this = source.next
                if this is None:
                    break # end of pattern
                if this in "|)":
                    break # end of subpattern
                sourceget()
        
                if verbose:
                    # skip whitespace and comments
                    if this in WHITESPACE:
                        continue
                    if this == "#":
                        while True:
                            this = sourceget()
                            if this is None or this == "\n":
                                break
                        continue
        
                if this[0] == "\\":
                    code = _escape(source, this, state)
                    subpatternappend(code)
        
                elif this not in SPECIAL_CHARS:
                    subpatternappend((LITERAL, _ord(this)))
        
                elif this == "[":
                    here = source.tell() - 1
                    # character set
                    set = []
                    setappend = set.append
        ##          if sourcematch(":"):
        ##              pass # handle character classes
                    if source.next == '[':
                        import warnings
                        warnings.warn(
                            'Possible nested set at position %d' % source.tell(),
                            FutureWarning, stacklevel=nested + 6
                        )
                    negate = sourcematch("^")
                    # check remaining characters
                    while True:
                        this = sourceget()
                        if this is None:
                            raise source.error("unterminated character set",
                                               source.tell() - here)
                        if this == "]" and set:
                            break
                        elif this[0] == "\\":
                            code1 = _class_escape(source, this)
                        else:
                            if set and this in '-&~|' and source.next == this:
                                import warnings
                                warnings.warn(
                                    'Possible set %s at position %d' % (
                                        'difference' if this == '-' else
                                        'intersection' if this == '&' else
                                        'symmetric difference' if this == '~' else
                                        'union',
                                        source.tell() - 1),
                                    FutureWarning, stacklevel=nested + 6
                                )
                            code1 = LITERAL, _ord(this)
                        if sourcematch("-"):
                            # potential range
                            that = sourceget()
                            if that is None:
                                raise source.error("unterminated character set",
                                                   source.tell() - here)
                            if that == "]":
                                if code1[0] is IN:
                                    code1 = code1[1][0]
                                setappend(code1)
                                setappend((LITERAL, _ord("-")))
                                break
                            if that[0] == "\\":
                                code2 = _class_escape(source, that)
                            else:
                                if that == '-':
                                    import warnings
                                    warnings.warn(
                                        'Possible set difference at position %d' % (
                                            source.tell() - 2),
                                        FutureWarning, stacklevel=nested + 6
                                    )
                                code2 = LITERAL, _ord(that)
                            if code1[0] != LITERAL or code2[0] != LITERAL:
                                msg = "bad character range %s-%s" % (this, that)
                                raise source.error(msg, len(this) + 1 + len(that))
                            lo = code1[1]
                            hi = code2[1]
                            if hi < lo:
                                msg = "bad character range %s-%s" % (this, that)
    >                           raise source.error(msg, len(this) + 1 + len(that))
    E                           re.error: bad character range z-a at position 1
    
    /usr/local/lib/python3.12/re/_parser.py:617: error
    =========================== short test summary info ============================
    FAILED test_pathmatch.py::test_reversed_bracket_range_does_not_raise - re.err...
    1 failed, 21 passed in 0.13s
    
  5. coder iteration 2 Finished 3 calls · 35.6K tokens · $0.0029 · 13.7s

    Loading…

  6. tester iteration 2 Finished 4 calls · 45.7K tokens · $0.0049 · 39.4s

    Loading…

  7. verify iteration 2 Failed 2.5s
    ......................F....                                              [100%]
    =================================== FAILURES ===================================
    _________________ test_double_star_inside_segment_keeps_slash __________________
    
        def test_double_star_inside_segment_keeps_slash():
            # '**' that is not a whole '**/' segment must not swallow the following '/'.
    >       assert match("x**/a", "x/a")
    E       AssertionError: assert False
    E        +  where False = match('x**/a', 'x/a')
    
    test_pathmatch.py:135: AssertionError
    =========================== short test summary info ============================
    FAILED test_pathmatch.py::test_double_star_inside_segment_keeps_slash - Asser...
    1 failed, 26 passed in 1.99s
    
  8. coder iteration 3 Finished 3 calls · 27.7K tokens · $0.0019 · 13.9s

    Loading…

  9. tester iteration 3 Finished 8 calls · 103.8K tokens · $0.0058 · 2m 55s

    Loading…

  10. verify iteration 3 Passed 2.5s
    ...........................                                              [100%]
    27 passed in 1.93s
    
  11. reviewer iteration 3 Finished 2 calls · 20.4K tokens · $0.0029 · 17.2s

    Loading…

Final diff

diff --git a/pathmatch.py b/pathmatch.py
index 42f6c2a..931fcdf 100644
--- a/pathmatch.py
+++ b/pathmatch.py
@@ -1,5 +1,93 @@
-import fnmatch
+import functools
+import re
+
+
+def _translate_class(pattern: str, i: int):
+    """Translate a bracket expression starting at pattern[i] == '['.
+
+    Returns (regex, next_index), or None if there is no closing ']'.
+    """
+    j = i + 1
+    negate = False
+    if j < len(pattern) and pattern[j] == "!":
+        negate = True
+        j += 1
+    start = j
+    if j < len(pattern) and pattern[j] == "]":
+        j += 1  # a leading ']' is a literal member
+    end = pattern.find("]", j)
+    if end == -1:
+        return None
+
+    content = pattern[start:end]
+    members = []
+    k = 0
+    while k < len(content):
+        c = content[k]
+        if k + 2 < len(content) and content[k + 1] == "-":
+            # A reversed range matches nothing, so it is dropped (as fnmatch does)
+            # rather than being passed to re, which would raise re.error.
+            if c <= content[k + 2]:
+                members.append(re.escape(c) + "-" + re.escape(content[k + 2]))
+            k += 3
+        else:
+            members.append(re.escape(c))
+            k += 1
+
+    if negate:
+        # A negated class never matches '/'.
+        if not members:
+            return "[^/]", end + 1
+        return "[^/" + "".join(members) + "]", end + 1
+    if not members:
+        # An empty class (e.g. only reversed ranges) matches nothing.
+        return "(?!)", end + 1
+    return "[" + "".join(members) + "]", end + 1
+
+
+def _translate(pattern: str) -> str:
+    out = []
+    i = 0
+    n = len(pattern)
+    while i < n:
+        c = pattern[i]
+        if c == "*" and pattern.startswith("**", i):
+            if i + 2 < n and pattern[i + 2] == "/" and (i == 0 or pattern[i - 1] == "/"):
+                # '**/' as a whole segment: zero or more whole directories.
+                out.append("(?:[^/]+/)*")
+                i += 3
+                continue
+            if i + 2 == n and i > 0 and pattern[i - 1] == "/":
+                # Trailing '/**': everything below the directory, not the directory.
+                out.append(".+")
+                i += 2
+                continue
+            # Standalone '**' not covered above: treat as two single stars.
+            out.append("[^/]*[^/]*")
+            i += 2
+            continue
+        if c == "*":
+            out.append("[^/]*")
+        elif c == "?":
+            out.append("[^/]")
+        elif c == "[":
+            cls = _translate_class(pattern, i)
+            if cls is None:
+                out.append(re.escape(c))
+            else:
+                regex, i = cls
+                out.append(regex)
+                continue
+        else:
+            out.append(re.escape(c))
+        i += 1
+    return "".join(out)
+
+
+@functools.lru_cache(maxsize=256)
+def _compile(pattern: str):
+    return re.compile(_translate(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..3ff04b4 100644
--- a/test_pathmatch.py
+++ b/test_pathmatch.py
@@ -7,3 +7,157 @@ 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_matches_one_non_slash():
+    assert match("a?c", "abc")
+    assert not match("a?c", "a/c")
+    assert not match("a?c", "ac")
+    assert not match("a?c", "abbc")
+
+
+def test_globstar_leading():
+    assert match("**/a.py", "a.py")
+    assert match("**/a.py", "x/y/a.py")
+    assert not match("**/a.py", "x/ya.py")
+
+
+def test_globstar_middle():
+    assert match("a/**/b", "a/b")
+    assert match("a/**/b", "a/x/y/b")
+    assert not match("a/**/b", "ab")
+
+
+def test_trailing_globstar():
+    assert match("src/**", "src/a")
+    assert match("src/**", "src/a/b")
+    assert not match("src/**", "src")
+    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_bracket_unclosed_is_literal():
+    assert match("[x", "[x")
+    assert not match("[x", "x")
+
+
+def test_literal_metacharacters():
+    assert match("a.c", "a.c")
+    assert not match("a.c", "abc")
+    assert match("a+b", "a+b")
+    assert not match("a+b", "aab")
+    assert match("(x)", "(x)")
+    assert not match("(x)", "x")
+
+
+def test_whole_path_anchoring():
+    assert match("a.py", "a.py")
+    assert not match("a.py", "x/a.py")
+    assert not match("a.py", "a.pyc")
+
+
+def test_bracket_leading_close_bracket_is_member():
+    assert match("[]]x", "]x")
+    assert not match("[]]x", "ax")
+
+
+def test_bracket_metacharacters_are_literal():
+    assert match("[*]x", "*x")
+    assert not match("[*]x", "ax")
+    assert match("[?]", "?")
+    assert match("[^a]x", "^x")
+    assert match("[\\\\]x", "\\x")
+
+
+def test_bracket_range():
+    assert match("[a-c]x", "bx")
+    assert not match("[a-c]x", "dx")
+    assert match("[a-]x", "-x")
+
+
+def test_negated_bracket_range_excludes_slash():
+    assert not match("[!a-c]x", "/x")
+    assert match("[!a-c]x", "dx")
+
+
+def test_unclosed_bracket_does_not_raise():
+    assert match("[", "[")
+    assert match("a[", "a[")
+    assert match("[]", "[]")
+
+
+def test_regex_special_characters_are_literal():
+    assert match("a$b", "a$b")
+    assert match("a|b", "a|b")
+    assert match("a{2}", "a{2}")
+    assert match("a\\b", "a\\b")
+    assert not match("a|b", "a")
+
+
+def test_standalone_double_star_does_not_cross_slash():
+    assert match("**", "ab")
+    assert not match("**", "a/b")
+
+
+def test_middle_globstar_does_not_match_partial_segment():
+    assert not match("a/**/b", "a/b/c")
+    assert not match("a/**/b", "a/xb")
+
+
+def test_trailing_globstar_matches_deep_paths():
+    assert match("src/**", "src/a/b/c.py")
+    assert not match("src/**", "srcx/a")
+
+
+def test_reversed_bracket_range_does_not_raise():
+    # fnmatch treats an invalid range as a non-match; match() should not
+    # leak re.error to callers.
+    assert match("[z-a]x", "bx") is False
+
+
+def test_double_star_inside_segment_keeps_slash():
+    # '**' that is not a whole '**/' segment must not swallow the following '/'.
+    assert match("x**/a", "x/a")
+    assert not match("x**/a", "xa")
+
+
+def test_double_star_inside_name_does_not_cross_slash():
+    assert not match("a**b", "a/b")
+    assert match("a**b", "axxb")
+
+
+def test_negated_bracket_with_special_members():
+    assert match("[!]]x", "ax")
+    assert not match("[!]]x", "]x")
+    assert not match("[!.]x", ".x")
+    assert match("[!.]x", "ax")
+
+
+def test_bracket_range_with_leading_close_bracket():
+    assert match("[]-a]x", "]x")
+    assert match("[]-a]x", "ax")
+    assert not match("[]-a]x", "bx")
+
+
+def test_pathological_pattern_is_fast():
+    import time
+
+    start = time.time()
+    assert not match("*a*a*a*a*a*a*a*a*b", "a" * 40)
+    assert not match("**a**a**a**a**a**b", "a/" * 40 + "a")
+    assert time.time() - start < 2.0