Repository: ota-meshi/eslint-plugin-regexp (ts)

Issue:
Report and fix confusing quantifiers around assertions
**Description**
Assertions can be used to force certain quantifiers (or alternatives) to be entered. 

E.g. in `a\b-?a`, the `\b` forces `-?` to be entered. The quantifier is useless in this case. 
`a\b-?a` ==<sub>this issue</sub> `a\b-a` ==<sub>no-useless-assertions</sub> `a-a`.

We need to check all quantifiers with a minimum of 0 that are next to an assertion. If the assertion forces us to enter the quantifier, then the minimum should be 1 instead.

---

I think this should be part of `regexp/confusing-quantifier` but I'm not certain. What do you think @ota-meshi?

Pull request description:
Add `no-contradiction-with-assertion` rule
This resolves #309.

Patch:
diff --git a/README.md b/README.md
index c41600c..09f06f0 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 
 | Rule ID | Description |    |
 |:--------|:------------|:---|
+| [regexp/no-contradiction-with-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-contradiction-with-assertion.html) | disallow elements that contradict assertions |  |
 | [regexp/no-dupe-disjunctions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-disjunctions.html) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-capturing-group.html) | disallow capturing group that captures empty. | :star: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index c0396fe..64d1897 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -13,6 +13,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 
 | Rule ID | Description |    |
 |:--------|:------------|:---|
+| [regexp/no-contradiction-with-assertion](./no-contradiction-with-assertion.md) | disallow elements that contradict assertions |  |
 | [regexp/no-dupe-disjunctions](./no-dupe-disjunctions.md) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](./no-empty-alternative.md) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](./no-empty-capturing-group.md) | disallow capturing group that captures empty. | :star: |
diff --git a/docs/rules/no-contradiction-with-assertion.md b/docs/rules/no-contradiction-with-assertion.md
new file mode 100644
index 0000000..f246fcb
--- /dev/null
+++ b/docs/rules/no-contradiction-with-assertion.md
@@ -0,0 +1,80 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/no-contradiction-with-assertion"
+description: "disallow elements that contradict assertions"
+---
+# regexp/no-contradiction-with-assertion
+
+> disallow elements that contradict assertions
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule reports elements that contradict an assertion. All elements reported by this rule fall into one of two categories:
+
+1. An element/alternative that can never be entered.
+
+    This means that the element is dead code and can be removed. Example:
+
+    <eslint-code-block>
+
+    ```js
+    /* eslint regexp/no-contradiction-with-assertion: "error" */
+
+    var foo = /(?=a)(\w|-)/;
+    var foo = /(?=a)b*a/;
+    ```
+
+    </eslint-code-block>
+
+2. An element that is always entered.
+
+    Right now, only quantifiers with a minimum of 0 are reported in this category. They are contradictory because the minimum of 0 is changed by the assertion to be effectively 1. Example:
+
+    <eslint-code-block>
+
+    ```js
+    /* eslint regexp/no-contradiction-with-assertion: "error" */
+
+    var foo = /a\b-?a/;
+    var foo = /^foo$[\s\S]*?^end foo/m;
+    ```
+
+    </eslint-code-block>
+
+This rule is quite similar to [regexp/no-useless-assertions]. While [regexp/no-useless-assertions] tries to find assertions that contradict the pattern, this rule tries to find parts of the pattern that contradict assertions.
+
+
+<eslint-code-block>
+
+```js
+/* eslint regexp/no-contradiction-with-assertion: "error" */
+
+/* ✓ GOOD */
+var foo = /a\b-a/;
+var foo = /a\ba/; // handled by regexp/no-useless-assertions
+
+/* ✗ BAD */
+var foo = /a\b-?a/;
+var foo = /a\b(a|-)/;
+var foo = /a\ba*-/;
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further reading
+
+- [regexp/no-useless-assertions]
+
+[regexp/no-useless-assertions]: ./no-useless-assertions.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-contradiction-with-assertion.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-contradiction-with-assertion.ts)
diff --git a/lib/rules/no-contradiction-with-assertion.ts b/lib/rules/no-contradiction-with-assertion.ts
new file mode 100644
index 0000000..93c48c2
--- /dev/null
+++ b/lib/rules/no-contradiction-with-assertion.ts
@@ -0,0 +1,408 @@
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { Assertion, Element, Alternative, Quantifier } from "regexpp/ast"
+import type { RegExpContext } from "../utils"
+import { quantToString, createRule, defineRegexpVisitor } from "../utils"
+import type {
+    FirstLookChar,
+    MatchingDirection,
+    ReadonlyFlags,
+} from "regexp-ast-analysis"
+import {
+    isPotentiallyEmpty,
+    getMatchingDirectionFromAssertionKind,
+    getFirstCharAfter,
+    getFirstConsumedChar,
+    getFirstConsumedCharAfter,
+    isZeroLength,
+    FirstConsumedChars,
+} from "regexp-ast-analysis"
+import { mention } from "../utils/mention"
+
+/**
+ * Returns whether the given assertions is guaranteed to always trivially
+ * reject or accept.
+ *
+ * @param assertion
+ */
+function isTrivialAssertion(
+    assertion: Assertion,
+    dir: MatchingDirection,
+    flags: ReadonlyFlags,
+): boolean {
+    if (assertion.kind !== "word") {
+        if (getMatchingDirectionFromAssertionKind(assertion.kind) !== dir) {
+            // This assertion doesn't assert anything in that direction, so
+            // it's the same as trivially accepting
+            return true
+        }
+    }
+
+    if (assertion.kind === "lookahead" || assertion.kind === "lookbehind") {
+        if (isPotentiallyEmpty(assertion.alternatives)) {
+            // The assertion is guaranteed to trivially accept/reject.
+            return true
+        }
+    }
+
+    const look = FirstConsumedChars.toLook(
+        getFirstConsumedChar(assertion, dir, flags),
+    )
+    if (look.char.isEmpty || look.char.isAll) {
+        // trivially rejecting or accepting (based on the first char)
+        return true
+    }
+
+    const after = getFirstCharAfter(assertion, dir, flags)
+    if (!after.edge) {
+        if (look.exact && look.char.isSupersetOf(after.char)) {
+            // trivially accepting
+            return true
+        }
+
+        if (look.char.isDisjointWith(after.char)) {
+            // trivially rejecting
+            return true
+        }
+    }
+
+    return false
+}
+
+/**
+ * Returns the next elements always reachable from the given element without
+ * consuming characters
+ */
+function* getNextElements(
+    start: Element,
+    dir: MatchingDirection,
+): Iterable<Element> {
+    let element = start
+
+    for (;;) {
+        const parent = element.parent
+
+        if (
+            parent.type === "CharacterClass" ||
+            parent.type === "CharacterClassRange"
+        ) {
+            return
+        }
+
+        if (parent.type === "Quantifier") {
+            if (parent.max === 1) {
+                element = parent
+                continue
+            } else {
+                return
+            }
+        }
+
+        const elements = parent.elements
+        const index = elements.indexOf(element)
+        const inc = dir === "ltr" ? 1 : -1
+        for (let i = index + inc; i >= 0 && i < elements.length; i += inc) {
+            const e = elements[i]
+            yield e
+            if (!isZeroLength(e)) {
+                return
+            }
+        }
+
+        // we have to check the grand parent
+        const grandParent = parent.parent
+
+        if (
+            (grandParent.type === "Group" ||
+                grandParent.type === "CapturingGroup" ||
+                (grandParent.type === "Assertion" &&
+                    getMatchingDirectionFromAssertionKind(grandParent.kind) !==
+                        dir)) &&
+            grandParent.alternatives.length === 1
+        ) {
+            element = grandParent
+            continue
+        }
+
+        return
+    }
+}
+
+/**
+ * Goes through the given element and all of its children until a the condition
+ * returns true or a character is (potentially) consumed.
+ */
+function tryFindContradictionIn(
+    element: Element,
+    dir: MatchingDirection,
+    condition: (e: Element | Alternative) => boolean,
+): boolean {
+    if (condition(element)) {
+        return true
+    }
+
+    if (element.type === "CapturingGroup" || element.type === "Group") {
+        // Go into the alternatives of groups
+        let some = false
+        element.alternatives.forEach((a) => {
+            if (tryFindContradictionInAlternative(a, dir, condition)) {
+                some = true
+            }
+        })
+        return some
+    }
+
+    if (element.type === "Quantifier" && element.max === 1) {
+        // Go into the element of quantifiers if their maximum is 1
+        return tryFindContradictionIn(element.element, dir, condition)
+    }
+
+    if (
+        element.type === "Assertion" &&
+        (element.kind === "lookahead" || element.kind === "lookbehind") &&
+        getMatchingDirectionFromAssertionKind(element.kind) === dir
+    ) {
+        // Go into the alternatives of lookarounds if they point in the same
+        // direction. E.g. ltr and (?=a).
+        // Since we don't consume characters, we want to keep going even if we
+        // find a contradiction inside the lookaround.
+        element.alternatives.forEach((a) =>
+            tryFindContradictionInAlternative(a, dir, condition),
+        )
+    }
+
+    return false
+}
+
+/**
+ * Goes through all elements of the given alternative until the condition
+ * returns true or a character is (potentially) consumed.
+ */
+function tryFindContradictionInAlternative(
+    alternative: Alternative,
+    dir: MatchingDirection,
+    condition: (e: Element | Alternative) => boolean,
+): boolean {
+    if (condition(alternative)) {
+        return true
+    }
+
+    const { elements } = alternative
+
+    const first = dir === "ltr" ? 0 : elements.length
+    const inc = dir === "ltr" ? 1 : -1
+    for (let i = first; i >= 0 && i < elements.length; i += inc) {
+        const e = elements[i]
+        if (tryFindContradictionIn(e, dir, condition)) {
+            return true
+        }
+        if (!isZeroLength(e)) {
+            break
+        }
+    }
+
+    return false
+}
+
+/**
+ * Returns whether the 2 look chars are disjoint (== mutually exclusive).
+ */
+function disjoint(a: FirstLookChar, b: FirstLookChar): boolean {
+    if (a.edge && b.edge) {
+        // both accept the edge
+        return false
+    }
+    return a.char.isDisjointWith(b.char)
+}
+
+export default createRule("no-contradiction-with-assertion", {
+    meta: {
+        docs: {
+            description: "disallow elements that contradict assertions",
+            category: "Possible Errors",
+            // TODO Switch to recommended in the major version.
+            // recommended: true,
+            recommended: false,
+        },
+        schema: [],
+        messages: {
+            alternative:
+                "The alternative {{ alt }} can never be entered because it contradicts with the assertion {{ assertion }}. Either change the alternative or assertion to resolve the contradiction.",
+            cannotEnterQuantifier:
+                "The quantifier {{ quant }} can never be entered because its element contradicts with the assertion {{ assertion }}. Change or remove the quantifier or change the assertion to resolve the contradiction.",
+            alwaysEnterQuantifier:
+                "The quantifier {{ quant }} is always entered despite having a minimum of 0. This is because the assertion {{ assertion }} contradicts with the element(s) after the quantifier. Either set the minimum to 1 ({{ newQuant }}) or change the assertion.",
+
+            // suggestions
+
+            removeQuantifier: "Remove the quantifier.",
+            changeQuantifier: "Change the quantifier to {{ newQuant }}.",
+        },
+        hasSuggestions: true,
+        type: "problem",
+    },
+    create(context) {
+        /** Create visitor */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const {
+                node,
+                flags,
+                getRegexpLocation,
+                fixReplaceQuant,
+                fixReplaceNode,
+            } = regexpContext
+
+            /** Analyses the given assertion. */
+            function analyseAssertion(
+                assertion: Assertion,
+                dir: MatchingDirection,
+            ) {
+                if (isTrivialAssertion(assertion, dir, flags)) {
+                    return
+                }
+
+                const assertionLook = FirstConsumedChars.toLook(
+                    getFirstConsumedChar(assertion, dir, flags),
+                )
+
+                for (const element of getNextElements(assertion, dir)) {
+                    if (tryFindContradictionIn(element, dir, contradicts)) {
+                        break
+                    }
+                }
+
+                /** Whether the alternative contradicts the current assertion. */
+                function contradictsAlternative(
+                    alternative: Alternative,
+                ): boolean {
+                    let consumed = getFirstConsumedChar(alternative, dir, flags)
+                    if (consumed.empty) {
+                        // This means that we also have to look at the
+                        // characters after the alternative.
+                        consumed = FirstConsumedChars.concat(
+                            [
+                                consumed,
+                                getFirstConsumedCharAfter(
+                                    alternative,
+                                    dir,
+                                    flags,
+                                ),
+                            ],
+                            flags,
+                        )
+                    }
+
+                    const look = FirstConsumedChars.toLook(consumed)
+
+                    if (disjoint(assertionLook, look)) {
+                        context.report({
+                            node,
+                            loc: getRegexpLocation(alternative),
+                            messageId: "alternative",
+                            data: {
+                                assertion: mention(assertion),
+                                alt: mention(alternative),
+                            },
+                        })
+                        return true
+                    }
+
+                    return false
+                }
+
+                /** Whether the alternative contradicts the current assertion. */
+                function contradictsQuantifier(quant: Quantifier): boolean {
+                    if (quant.max === 0) {
+                        return false
+                    }
+                    if (quant.min !== 0) {
+                        // all the below condition assume min=0
+                        return false
+                    }
+
+                    const consumed = getFirstConsumedChar(
+                        quant.element,
+                        dir,
+                        flags,
+                    )
+                    const look = FirstConsumedChars.toLook(consumed)
+
+                    if (disjoint(assertionLook, look)) {
+                        // This means that we cannot enter the quantifier
+                        // e.g. /(?!a)a*b/
+                        context.report({
+                            node,
+                            loc: getRegexpLocation(quant),
+                            messageId: "cannotEnterQuantifier",
+                            data: {
+                                assertion: mention(assertion),
+                                quant: mention(quant),
+                            },
+                            suggest: [
+                                {
+                                    messageId: "removeQuantifier",
+                                    fix: fixReplaceNode(quant, ""),
+                                },
+                            ],
+                        })
+                        return true
+                    }
+
+                    const after = getFirstCharAfter(quant, dir, flags)
+
+                    if (disjoint(assertionLook, after)) {
+                        // This means that we always have to enter the quantifier
+                        // e.g. /(?!a)b*a/
+                        const newQuant = quantToString({ ...quant, min: 1 })
+                        context.report({
+                            node,
+                            loc: getRegexpLocation(quant),
+                            messageId: "alwaysEnterQuantifier",
+                            data: {
+                                assertion: mention(assertion),
+                                quant: mention(quant),
+                                newQuant,
+                            },
+                            suggest: [
+                                {
+                                    messageId: "changeQuantifier",
+                                    data: { newQuant },
+                                    fix: fixReplaceQuant(quant, {
+                                        min: 1,
+                                        max: quant.max,
+                                    }),
+                                },
+                            ],
+                        })
+                        return true
+                    }
+
+                    return false
+                }
+
+                /** Whether the element contradicts the current assertion. */
+                function contradicts(element: Element | Alternative): boolean {
+                    if (element.type === "Alternative") {
+                        return contradictsAlternative(element)
+                    } else if (element.type === "Quantifier") {
+                        return contradictsQuantifier(element)
+                    }
+
+                    return false
+                }
+            }
+
+            return {
+                onAssertionEnter(assertion) {
+                    analyseAssertion(assertion, "ltr")
+                    analyseAssertion(assertion, "rtl")
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/rules/no-useless-assertions.ts b/lib/rules/no-useless-assertions.ts
index 0418f7f..dca0308 100644
--- a/lib/rules/no-useless-assertions.ts
+++ b/lib/rules/no-useless-assertions.ts
@@ -15,6 +15,7 @@ import {
     getMatchingDirectionFromAssertionKind,
     hasSomeDescendant,
     isPotentiallyEmpty,
+    FirstConsumedChars,
 } from "regexp-ast-analysis"
 import { mention } from "../utils/mention"
 
@@ -211,14 +212,13 @@ export default createRule("no-useless-assertions", {
                     return
                 }
 
-                const firstOf = getFirstConsumedChar(
-                    assertion.alternatives,
-                    direction,
-                    flags,
+                const firstOf = FirstConsumedChars.toLook(
+                    getFirstConsumedChar(
+                        assertion.alternatives,
+                        direction,
+                        flags,
+                    ),
                 )
-                if (firstOf.empty) {
-                    return
-                }
 
                 // the idea here is that a negate lookaround accepts when non-negated version reject and vise versa.
                 const accept = assertion.negate ? "reject" : "accept"
diff --git a/lib/utils/index.ts b/lib/utils/index.ts
index 18deafd..5ca48fc 100644
--- a/lib/utils/index.ts
+++ b/lib/utils/index.ts
@@ -8,7 +8,7 @@ import type { Rule, AST, SourceCode } from "eslint"
 import { getStringIfConstant } from "./ast-utils"
 import type { ReadonlyFlags, ToCharSetElement } from "regexp-ast-analysis"
 // eslint-disable-next-line no-restricted-imports -- Implement RegExpContext#toCharSet
-import { toCharSet } from "regexp-ast-analysis"
+import { toCharSet, toCache } from "regexp-ast-analysis"
 import type { CharSet } from "refa"
 import { JS } from "refa"
 import type { UsageOfPattern } from "./get-usage-of-pattern"
@@ -89,7 +89,7 @@ type RegExpContextBase = {
     patternAst: Pattern
     patternSource: PatternSource
 
-    flags: ReadonlyFlags
+    flags: Required<ReadonlyFlags>
 }
 export type RegExpContextForLiteral = {
     node: ESTree.RegExpLiteral
@@ -109,7 +109,7 @@ type UnparsableRegExpContextBase = {
     node: ESTree.Expression
     regexpNode: ESTree.RegExpLiteral | ESTree.CallExpression
 
-    flags: ReadonlyFlags
+    flags: Required<ReadonlyFlags>
     flagsString: string | null
     ownsFlags: boolean
 
@@ -578,7 +578,7 @@ function buildRegExpContextBase({
     regexpNode,
     flagsNode,
     context,
-    flags,
+    flags: originalFlags,
     parsedPattern,
 }: {
     patternSource: PatternSource
@@ -589,8 +589,8 @@ function buildRegExpContextBase({
     parsedPattern: Pattern
 }): RegExpContextBase {
     const sourceCode = context.getSourceCode()
+    const flags = toCache(originalFlags)
 
-    const cacheCharSet = new WeakMap<ToCharSetElement, CharSet>()
     let cacheUsageOfPattern: UsageOfPattern | null = null
     return {
         toCharSet: (node, optionFlags) => {
@@ -599,13 +599,7 @@ function buildRegExpContextBase({
                 return toCharSet(node, optionFlags)
             }
 
-            let charSet = cacheCharSet.get(node)
-            if (charSet) {
-                return charSet
-            }
-            charSet = toCharSet(node, flags)
-            cacheCharSet.set(node, charSet)
-            return charSet
+            return toCharSet(node, flags)
         },
         getRegexpLocation: (range, offsets) => {
             if (offsets) {
@@ -652,7 +646,7 @@ function buildUnparsableRegExpContextBase({
     patternNode,
     regexpNode,
     context,
-    flags,
+    flags: originalFlags,
     flagsString,
     flagsNode,
     ownsFlags,
@@ -667,6 +661,7 @@ function buildUnparsableRegExpContextBase({
     ownsFlags: boolean
 }): UnparsableRegExpContextBase {
     const sourceCode = context.getSourceCode()
+    const flags = toCache(originalFlags)
 
     return {
         regexpNode,
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index f391fcd..f332a51 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -6,6 +6,7 @@ import letterCase from "../rules/letter-case"
 import matchAny from "../rules/match-any"
 import negation from "../rules/negation"
 import noAssertionCapturingGroup from "../rules/no-assertion-capturing-group"
+import noContradictionWithAssertion from "../rules/no-contradiction-with-assertion"
 import noDupeCharactersCharacterClass from "../rules/no-dupe-characters-character-class"
 import noDupeDisjunctions from "../rules/no-dupe-disjunctions"
 import noEmptyAlternative from "../rules/no-empty-alternative"
@@ -74,6 +75,7 @@ export const rules = [
     matchAny,
     negation,
     noAssertionCapturingGroup,
+    noContradictionWithAssertion,
     noDupeCharactersCharacterClass,
     noDupeDisjunctions,
     noEmptyAlternative,


Test patch:
diff --git a/tests/lib/rules/no-contradiction-with-assertion.ts b/tests/lib/rules/no-contradiction-with-assertion.ts
new file mode 100644
index 0000000..1d62a10
--- /dev/null
+++ b/tests/lib/rules/no-contradiction-with-assertion.ts
@@ -0,0 +1,66 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/no-contradiction-with-assertion"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("no-contradiction-with-assertion", rule as any, {
+    valid: [
+        // Ignore trivially accepting/rejecting assertions
+        String.raw`/a\ba/`,
+        String.raw`/(?!)a/`,
+        String.raw`/(?=)a/`,
+        String.raw`/$a/`,
+
+        // Other valid regexes
+        String.raw`/(^|[\s\S])\bfoo/`,
+        String.raw`/(?:aa|a\b)-?a/`,
+    ],
+    invalid: [
+        {
+            code: String.raw`/a\b-?a/`,
+            errors: [
+                {
+                    messageId: "alwaysEnterQuantifier",
+                    suggestions: [{ output: String.raw`/a\b-{1}a/` }],
+                },
+            ],
+        },
+        {
+            code: String.raw`/a\b(a|-)/`,
+            errors: [
+                {
+                    messageId: "alternative",
+                    suggestions: [],
+                },
+            ],
+        },
+        {
+            code: String.raw`/a\ba*-/`,
+            errors: [
+                {
+                    messageId: "cannotEnterQuantifier",
+                    suggestions: [{ output: String.raw`/a\b-/` }],
+                },
+            ],
+        },
+
+        {
+            code: String.raw`/(^[\t ]*)#(?:comments-start|cs)[\s\S]*?^[ \t]*#(?:comments-end|ce)/m`,
+            errors: [
+                {
+                    messageId: "alwaysEnterQuantifier",
+                    suggestions: [
+                        {
+                            output: String.raw`/(^[\t ]*)#(?:comments-start|cs)[\s\S]+?^[ \t]*#(?:comments-end|ce)/m`,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+})
diff --git a/tests/lib/rules/no-useless-assertions.ts b/tests/lib/rules/no-useless-assertions.ts
index d1a7ba4..a511603 100644
--- a/tests/lib/rules/no-useless-assertions.ts
+++ b/tests/lib/rules/no-useless-assertions.ts
@@ -356,5 +356,9 @@ tester.run("no-useless-assertions", rule as any, {
                 },
             ],
         },
+        {
+            code: String.raw`/(?=a|$)b/u`,
+            errors: ["The lookahead '(?=a|$)' will always reject."],
+        },
     ],
 })