match() treats paths wrongly
Done- Plan (done)
- Code (done)
- Test (done)
- Review (done)
- Finish (done)
Cost
$0.0268
Tests
Passed
Iterations
3
Re-plans
0
LLM calls
31
Prompt read from cache
76%
Usage by agent
- Cache read
- Cache write
- Input
- Output
View as table
| Agent | Cost | Cache read | Cache write | Input | Output |
|---|---|---|---|---|---|
| planner | $0.0014 | 6,097 | 992 | 8 | 2,484 |
| coder | $0.0083 | 60,656 | 24,001 | 26 | 9,312 |
| tester | $0.0143 | 136,579 | 33,136 | 38 | 17,479 |
| reviewer | $0.0029 | 7,207 | 10,074 | 6 | 3,133 |
Timeline
-
planner iteration 1 Finished 3 calls · 9,581 tokens · $0.0014 · 12.9s
Loading…
-
coder iteration 1 Finished 4 calls · 30.7K tokens · $0.0035 · 21.2s
Loading…
-
tester iteration 1 Finished 4 calls · 37.7K tokens · $0.0035 · 28.0s
Loading…
-
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 -
coder iteration 2 Finished 3 calls · 35.6K tokens · $0.0029 · 13.7s
Loading…
-
tester iteration 2 Finished 4 calls · 45.7K tokens · $0.0049 · 39.4s
Loading…
-
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 -
coder iteration 3 Finished 3 calls · 27.7K tokens · $0.0019 · 13.9s
Loading…
-
tester iteration 3 Finished 8 calls · 103.8K tokens · $0.0058 · 2m 55s
Loading…
-
verify iteration 3 Passed 2.5s
........................... [100%] 27 passed in 1.93s
-
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