Repository: Instagram/LibCST

Issue:
[BUG] SavedMatchedNode affects matching
Wrapping a matcher with `SaveMatchedNode` sometimes breaks the matching.

`libcst` version: 0.3.7 (installed with pip)

See the test below

```py
import libcst.matchers as m
import libcst

node = libcst.parse_expression("[1, 2, 3]")

matcher = m.List(
    elements=[
        m.SaveMatchedNode(m.AtMostN(n=2), "head"),
        m.SaveMatchedNode(m.Element(value=m.Integer(value="3")), "element"),
        m.AtMostN(n=1),
    ]
)
print(m.extract(node, matcher))
print(m.matches(node, matcher))

matcher = m.List(
    elements=[
        m.SaveMatchedNode(m.AtMostN(n=2), "head"),
        m.SaveMatchedNode(m.Element(value=m.Integer(value="3")), "element"),
        m.SaveMatchedNode(m.AtMostN(n=1), "tail"),
    ]
)
print(m.extract(node, matcher))
print(m.matches(node, matcher))
```

The first matcher fails, while the second succeeds. The only difference is the `tail` `SaveMatchedNode`.

Comments:

Patch:
diff --git a/libcst/matchers/_matcher_base.py b/libcst/matchers/_matcher_base.py
index 27475d5..0cf281c 100644
--- a/libcst/matchers/_matcher_base.py
+++ b/libcst/matchers/_matcher_base.py
@@ -932,6 +932,24 @@ def SaveMatchedNode(matcher: _OtherNodeT, name: str) -> _OtherNodeT:
     return cast(_OtherNodeT, _ExtractMatchingNode(matcher, name))
 
 
+def _matches_zero_nodes(
+    matcher: Union[
+        BaseMatcherNode,
+        _BaseWildcardNode,
+        MatchIfTrue[Callable[[object], bool]],
+        _BaseMetadataMatcher,
+        DoNotCareSentinel,
+    ]
+) -> bool:
+    if isinstance(matcher, AtLeastN) and matcher.n == 0:
+        return True
+    if isinstance(matcher, AtMostN):
+        return True
+    if isinstance(matcher, _ExtractMatchingNode):
+        return _matches_zero_nodes(matcher.matcher)
+    return False
+
+
 @dataclass(frozen=True)
 class _SequenceMatchesResult:
     sequence_capture: Optional[
@@ -960,14 +978,13 @@ def _sequence_matches(  # noqa: C901
         return _SequenceMatchesResult({}, None)
     if not nodes and matchers:
         # Base case, we have one or more matcher that wasn't matched
-        return (
-            _SequenceMatchesResult({}, [])
-            if all(
-                (isinstance(m, AtLeastN) and m.n == 0) or isinstance(m, AtMostN)
-                for m in matchers
+        if all(_matches_zero_nodes(m) for m in matchers):
+            return _SequenceMatchesResult(
+                {m.name: () for m in matchers if isinstance(m, _ExtractMatchingNode)},
+                (),
             )
-            else _SequenceMatchesResult(None, None)
-        )
+        else:
+            return _SequenceMatchesResult(None, None)
     if nodes and not matchers:
         # Base case, we have nodes left that don't match any matcher
         return _SequenceMatchesResult(None, None)


Test patch:
diff --git a/libcst/matchers/tests/test_extract.py b/libcst/matchers/tests/test_extract.py
index 5c3cf12..77c134a 100644
--- a/libcst/matchers/tests/test_extract.py
+++ b/libcst/matchers/tests/test_extract.py
@@ -322,6 +322,34 @@ class MatchersExtractTest(UnitTest):
         )
         self.assertEqual(nodes, {})
 
+    def test_extract_optional_wildcard_head(self) -> None:
+        expression = cst.parse_expression("[3]")
+        nodes = m.extract(
+            expression,
+            m.List(
+                elements=[
+                    m.SaveMatchedNode(m.ZeroOrMore(), "head1"),
+                    m.SaveMatchedNode(m.ZeroOrMore(), "head2"),
+                    m.Element(value=m.Integer(value="3")),
+                ]
+            ),
+        )
+        self.assertEqual(nodes, {"head1": (), "head2": ()})
+
+    def test_extract_optional_wildcard_tail(self) -> None:
+        expression = cst.parse_expression("[3]")
+        nodes = m.extract(
+            expression,
+            m.List(
+                elements=[
+                    m.Element(value=m.Integer(value="3")),
+                    m.SaveMatchedNode(m.ZeroOrMore(), "tail1"),
+                    m.SaveMatchedNode(m.ZeroOrMore(), "tail2"),
+                ]
+            ),
+        )
+        self.assertEqual(nodes, {"tail1": (), "tail2": ()})
+
     def test_extract_optional_wildcard_present(self) -> None:
         expression = cst.parse_expression("a + b[c], d(e, f * g, h.i.j)")
         nodes = m.extract(