Repository: speckit (difficulty: medium)

Issue:
glob_match translates the pattern to a regular expression but copies literal characters into the regex untouched, so any pattern containing regex punctuation misbehaves.

Observed vs expected:
- glob_match("a.b", "axb") returns True; expected False (the "." must be a literal dot, not any-character)
- glob_match("a+b", "a+b") returns False; expected True
- glob_match("(x)", "(x)") returns False; expected True
- glob_match("(x)", "x") returns True; expected False
- glob_match("a|b", "a") returns True; expected False (the pattern is the three literal characters a, |, b)
- glob_match("$", "$") returns False; expected True
- glob_match("^", "^") returns False; expected True

Wildcards, character classes, and anchoring already work and must keep working: "*" matches any sequence including the empty one, "?" matches exactly one character, "[seq]" is a class that may contain "l-h" ranges, "[!seq]" is a negated class, a "]" listed first in a class is a literal "]", an unmatched "[" is a literal "[", and matching is anchored so the whole name must match the whole pattern ("b" does not match "abc" and "*.txt" does not match "a.txt.bak"). As the module docstring states, the intended semantics for every one of these constructs are exactly Python's fnmatch.fnmatchcase, case sensitive. Only the handling of literal characters is broken.

Source files:
core.py: """Anchored fnmatch-style glob matching.

glob_match(pattern, name) reports whether ``name`` matches ``pattern``.

Contract (identical to Python's fnmatch.fnmatchcase for all of these
constructs, and case sensitive):

- "*" matches any sequence of characters, including the empty one.
- "?" matches exactly one character.
- "[seq]" matches one character from seq; seq may contain plain
  characters and low-high ranges written "l-h".
- "[!seq]" matches one character not in seq.
- A "]" listed as the first member of a class (right after "[" or
  "[!") is the literal "]" character.
- A "[" with no closing "]" anywhere after it is an ordinary literal
  "[" character.
- Every character that is not part of a wildcard or class matches
  itself literally; that includes regex punctuation such as ".", "+",
  "(", ")", "$", "^", and "|".
- Matching is anchored: the whole name must match the whole pattern,
  so pattern "b" does not match "abc" and "*.txt" does not match
  "a.txt.bak".
"""

import re


def _translate(pattern):
    """Translate a glob pattern into an anchored regular expression body."""
    pieces = []
    i = 0
    n = len(pattern)
    while i < n:
        char = pattern[i]
        i += 1
        if char == "*":
            pieces.append(".*")
        elif char == "?":
            pieces.append(".")
        elif char == "[":
            j = i
            if j < n and pattern[j] == "!":
                j += 1
            if j < n and pattern[j] == "]":
                j += 1
            while j < n and pattern[j] != "]":
                j += 1
            if j >= n:
                pieces.append("\\[")
            else:
                inner = pattern[i:j]
                i = j + 1
                if inner.startswith("!"):
                    inner = "^" + inner[1:]
                elif inner.startswith("^"):
                    inner = "\\" + inner
                pieces.append("[" + inner + "]")
        else:
            pieces.append(char)
    return "".join(pieces)


def glob_match(pattern, name):
    """Return True iff name matches the glob pattern (anchored)."""
    return re.fullmatch(_translate(pattern), name) is not None


Test files:
test_core.py: """Tests for anchored fnmatch-style glob matching."""

import fnmatch
import random

from core import glob_match


def test_literal_regex_punctuation():
    assert glob_match("a.b", "a.b")
    assert not glob_match("a.b", "axb")
    assert glob_match("a+b", "a+b")
    assert not glob_match("a+b", "aab")
    assert glob_match("(x)", "(x)")
    assert not glob_match("(x)", "x")
    assert glob_match("$", "$")
    assert glob_match("^", "^")
    assert glob_match("a|b", "a|b")
    assert not glob_match("a|b", "a")
    assert not glob_match("a|b", "b")


def test_wildcards():
    assert glob_match("*", "")
    assert glob_match("*", "anything")
    assert glob_match("*.txt", "note.txt")
    assert glob_match("a*c", "abbbc")
    assert glob_match("a*c", "ac")
    assert not glob_match("?", "")
    assert glob_match("?", "a")
    assert glob_match("??", "ab")
    assert not glob_match("??", "abc")
    assert glob_match("a?c", "abc")
    assert not glob_match("a?c", "abbc")


def test_character_classes():
    assert glob_match("[abc]", "b")
    assert not glob_match("[abc]", "d")
    assert glob_match("[a-f]1", "c1")
    assert not glob_match("[a-f]1", "g1")
    assert glob_match("[!a-f]1", "g1")
    assert not glob_match("[!a-f]1", "c1")
    assert glob_match("[]a]x", "]x")
    assert glob_match("[]a]x", "ax")
    assert not glob_match("[]a]x", "bx")
    assert glob_match("[!]a]x", "bx")
    assert not glob_match("[!]a]x", "]x")
    assert glob_match("a[", "a[")
    assert glob_match("a[bc", "a[bc")
    assert not glob_match("a[bc", "abc")


def test_anchoring():
    assert not glob_match("b", "abc")
    assert not glob_match("b", "ab")
    assert not glob_match("*.txt", "a.txt.bak")
    assert not glob_match("abc", "abcd")
    assert not glob_match("abc", "zabc")


def test_random_cross_check_against_fnmatchcase():
    # The documented contract for every construct generated here is
    # exactly fnmatch.fnmatchcase semantics, so use it as the oracle.
    literals = "abcxyz019.+()$^| "
    name_chars = literals + "]["
    rng = random.Random(20260702)

    def random_class():
        negate = "!" if rng.random() < 0.4 else ""
        members = []
        for _ in range(rng.randint(1, 3)):
            if rng.random() < 0.5:
                low, high = sorted(rng.sample("abcdefghij05", 2))
                members.append(low + "-" + high)
            else:
                members.append(rng.choice("abcdxyz019"))
        return "[" + negate + "".join(members) + "]"

    def random_pattern():
        parts = []
        for _ in range(rng.randint(0, 6)):
            kind = rng.randint(0, 8)
            if kind <= 4:
                parts.append(rng.choice(literals))
            elif kind == 5:
                parts.append("*")
            elif kind == 6:
                parts.append("?")
            else:
                parts.append(random_class())
        return "".join(parts)

    for _ in range(600):
        pattern = random_pattern()
        name = "".join(rng.choice(name_chars) for _ in range(rng.randint(0, 8)))
        expected = fnmatch.fnmatchcase(name, pattern)
        assert glob_match(pattern, name) == expected, (pattern, name)


Patch:
--- a/core.py
+++ b/core.py
@@ -56,7 +56,7 @@
                     inner = "\\" + inner
                 pieces.append("[" + inner + "]")
         else:
-            pieces.append(char)
+            pieces.append(re.escape(char))
     return "".join(pieces)