All findings · Lucene finding · LFV-019 · regexp-escaped-range-start

RegExp no longer parses an escaped range start such as [\.-z] as a range

Since Lucene 10.2.0, RegExp reads a class with an escaped range start, such as [\.-z], as three single characters (., -, z) instead of the range . to z. RegexpQuery and the classic query parser's /.../ syntax silently match other terms: 3 instead of 5 in a six-term index.

Summary

Priority
Medium, rank 37A default-syntax RegExp regression since 10.2 silently changes what a character-class range starting with an escaped character such as [\.-z] matches (an Elasticsearch role pattern could widen access), with a cheap parser fix, but such ranges are rare.
Impact
silent wrong results
Since
10.2.0introduced 2025-02-13 · about 1 year
Affected
10.2.0 to 10.5.2 and main; correct on 9.12.2, 9.12.3, 10.0.0 and 10.1.0 (run)
Status
documented
Found by
Jazzer differential fuzzing against java.util.regex

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Summary card titled 'Lucene RegExp: an escaped range start such as [\.-z] is no longer a range'. Panel 1, You write: new RegexpQuery(new Term("f", "[\\.-z]")), the regex text [\.-z] as six tiles, and the javadoc grammar lines charclass ::= charexp - charexp (character range) and charexp ::= \ <Unicode character> (a single character). Panel 2, how RegExp.parseCharClasses() reads it: expected one range '.' to 'z' (REGEXP_CHAR_RANGE from=. to=z); actual three single characters '.', '-', 'z' (REGEXP_CHAR_CLASS); RegExp.java:1318-1323 adds the escaped character on its own, and only the unescaped branch checks match('-') at line 1328. Panel 3, the search over six one-term documents: expected 5 hits, actual 3, no exception: silently wrong; '-' is a wrong match and three terms are missed. Footer: 9.12.2 to 10.1.0 give 5 hits, every release 10.2.0 to 10.5.2 and main give 3, introduced by #14227; [\!--] now throws IllegalArgumentException.
Select the still to open it at full size. Generated with AI, checked against the code.
  1. Write [\.-z], escaping the reserved . as the RegExp javadoc asks ("also in character classes"), and run it as a RegexpQuery over six one-character terms: a m z . - /.
  2. You expect the range . to z (77 of the 95 printable ASCII characters) and 5 hits, as on 10.1.0 and in java.util.regex.
  3. Since 10.2.0 you get three single characters, ., - and z: 3 hits, no error. 75 of the 77 characters are missing, and - is added.
  4. The cause: a branch that #14227 added to RegExp.parseCharClasses() adds an escaped character (other than an ASCII letter or \) on its own and never checks for a following -. Unescaped starts ([.-z]) and escaped ends ([a-\~]) still work.
  5. Other escaped starts break too: [\!-\~] matches 3 characters instead of 94, [\0-\9] (how RegExp.toString() prints [0-9]) 3 instead of 10, and [\!--] throws IllegalArgumentException.

Who is affected

Anyone who passes RegExp a class range whose start is an escaped character other than an ASCII letter or \ (new RegExp(...), RegexpQuery, the classic query parser's /.../ syntax) on 10.2.0 to 10.5.2 or main. It is the default, with no flag. The javadoc's escaping rule leads to such patterns; how common they are is unknown.

Mechanism, terms and numbers808 words

Write the character class [\.-z] and you mean every character from . to z: 77 of the 95 printable ASCII characters. Since Lucene 10.2.0, RegExp reads it as just three characters, ., - and z. So RegexpQuery and the classic query parser's /.../ syntax silently match other terms (3 instead of 5 in a six-term index), and a valid pattern such as [\!--] throws. Lucene 10.1.0, java.util.regex and the grammar in the RegExp javadoc all read [\.-z] as a range. The cause is a parser branch that #14227 added: it never checks for a - after an escaped character.

What you write

You want terms made of one character between . and z. Since . is a reserved character, the RegExp javadoc tells you to escape it, and to do so "also in character classes". So you write:

new RegexpQuery(new Term("f", "[\\.-z]"));   // regex text: [\.-z]

What you expect

The range from . (U+002E) to z (U+007A): 77 of the 95 printable ASCII characters, including /, the digits, A-Z and a-z. It does not include - (U+002D), one code point below the dot.

What you get, since 10.2.0

Exactly three characters: ., - and z. 75 of the 77 characters you wanted are missing, and -, which is outside the range, is added. Index six one-character terms a m z . - / and count the hits:

queryLucene 10.1.0Lucene 10.2.0 to main
[.-z] (no backslash)55
[\.-z] (escaped start)53 (. - z): no error

Other spellings behave the same way (printable ASCII characters accepted, on 10.1.0 against 10.5.2 and main):

  • [\!-\~], meaning "printable ASCII without space": 94 on 10.1.0, 3 (! - ~) now.
  • [\+-0]: 6 (+ , - . / 0), now 3 (+ - 0).
  • [\!--], the range ! to -: 13, now IllegalArgumentException: expected ']' at position 6.
  • [\0-\9], which is what RegExp.toString() prints for [0-9]: 10, now 3 (0 - 9).
  • Unchanged: [.-z] (unescaped start), [a-\~] (escaped end) and [\-] (an escaped dash on its own).

java.util.regex agrees with 10.1.0 on every row above that it accepts (it rejects \0).

Why: one missing check

RegExp.parseCharClasses() reads a class one item per loop round (RegExp.java:1312-1348 at ea57360912ed).

  • Unescaped item. It reads a character (1326) and then asks "is the next character -?" (match('-'), 1328). If so, it builds a range up to the character after the dash (1333-1334).
  • Escaped item. After a backslash (1314), a character that is not a letter or \ goes to a separate branch (1318-1323, comment "escaped character, don't parse it"). That branch adds the character as a single item and never asks about the dash.

So for [\.-z] (positions 0-5 hold [ \ . - z ]):

roundwhat the parser doesitem added
1\ matched (1314); . is not a letter (1315); c = next() (1320). on its own; the - at position 3 is never checked
2reads - as a plain character (1326); the next character is z, not - (1328)- on its own
3reads z (1326); the next character is ]z on its own
endthe loop stops at ] (1348); ] is matched (1303)the class {., -, z}

For [\!--], round 2 reads the first -, sees the second -, and builds a range from - to the next character, which is the closing ]. Nothing is left to close the class, so the parser throws "expected ']' at position 6" (1303).

An escaped range end still works. The end is read by parseCharExp() (1502-1505), which accepts an optional backslash, so [a-\~] is still a range.

What 10.1.0 did

parseCharClass() (lines 1203-1212 at releases/lucene/10.1.0) consumed the backslash, read the character with parseCharExp(), and then ran the same match('-') range check for both spellings (1209-1210). #14193 (fe42efc5918, 2025-02-06) rewrote this into the parseCharClasses loop, where an escaped non-letter still reached the range check by falling through to the next loop round. #14227 (19be0e5fd11, 2025-02-13) then added the branch above. Both were first released in 10.2.0.

Which escapes are hit: a backslash followed by any character that is not an ASCII letter and not \. That covers digits, punctuation, space and non-ASCII characters, all executed: [\0-\9] gives 3 characters instead of 10, [\ -z] (escaped space) 3 printable ASCII characters instead of 91, and [\é-ü] only é, - and ü instead of the 20 characters from U+00E9 to U+00FC. An escaped backslash as the start ([\\-z]) was never a range, not even in 10.1.0; that older quirk is separate.

Exposure and likelihood370 words
  • Who is hit. Any code that passes such a pattern to RegExp:
    • new RegExp(...) itself;
    • RegexpQuery, whose default constructor uses RegExp.ALL (RegexpQuery.java:54-55);
    • the classic QueryParser /.../ syntax: /[\.-z]/ counts 3 instead of 5 on ea57360912ed [exec];
    • not run: the flexible StandardQueryParser, other AutomatonQuery users and downstream engines.
  • Default, not opt-in. No flag is involved: the escaped branch is unconditional. [\.-z] misses a with RegExp.ALL and with RegExp.NONE [exec].
  • Why people write escaped starts.
    • The javadoc requires escaping reserved characters "also in character classes" (RegExp.java:342-345), so following the docs gives [\.-z].
    • RegExp.toString() itself prints [0-9] as [\0-\9] (on 10.1.0, 10.5.2 and main [exec]). On 10.2.0 and later that string re-parses to 3 characters. Upstream does not promise that toString() output can be re-parsed (rmuir in #14493), so this is only a side effect.
  • Silent. No error, and the matched set is wrong in both directions: most of the range is dropped, and a literal - is added. An exception is raised only in a few shapes, such as a range that ends in -.
  • Breadth. The candidate's bounded-exhaustive differential tried the 1,867 two-endpoint classes [\s-e] that java.util.regex accepts, with s one of the 31 ASCII punctuation characters other than \, and e >= s a printable ASCII character other than [, ], \ and &. On main and 10.5.2, 1,852 give a different language from java.util.regex, 13 throw, and the 2 that agree do so by coincidence. On 10.1.0: 0 and 0. Space and digit starts are not in that set (java.util.regex rejects \0 to \9); they were run separately and are affected too (see "Which escapes are hit" above).
  • Scale. None needed: a six-character pattern triggers it.
  • Released. Every release from 10.2.0 to 10.5.2, and main. Moving from 10.1.0 or an older release changes results with no CHANGES or MIGRATE entry. The clean releases that were run are 9.12.2, 9.12.3, 10.0.0 and 10.1.0; by code reading, 7.0.0, 8.0.0 and 9.0.0 have the same range check for an escaped start.
  • How common. Unknown. No report was found in the roughly 18 months since 10.2.0. Patterns with an unescaped range start are unaffected, which limits the reach.

Minimal repro

The test shows that new RegExp("[\\.-z]") (a character-class range whose start is escaped) is not parsed as the range '.'..'z' on main (regression since 10.2.0, #14227).

TestRegExpEscapedRangeStart.java (JUnit test)31 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util.automaton;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRegExpEscapedRangeStart extends LuceneTestCase {

  public void testEscapedRangeStart() {
    Automaton a = new RegExp("[\\.-z]").toAutomaton();
    CharacterRunAutomaton r =
        new CharacterRunAutomaton(
            Operations.determinize(a, Operations.DEFAULT_DETERMINIZE_WORK_LIMIT));
    assertTrue(
        "expected [\\.-z] (the range '.'..'z') to accept \"a\", but it rejects it", r.run("a"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-019/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.util.automaton.TestRegExpEscapedRangeStart
Expected vs actual
Expected: the automaton accepts "a" (javadoc grammar: charclass ::= charexp '-' charexp, charexp ::= '\' char). Actual: it rejects "a"; the class is parsed as the three characters '.', '-', 'z'.

All files of this repro: repro/LFV-019/

More files, environment, full output41 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected [\.-z] (the range '.'..'z') to accept "a", but it rejects it
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.util.automaton.TestRegExpEscapedRangeStart.testEscapedRangeStart(TestRegExpEscapedRangeStart.java:28)

Why it looks unintended

  • The RegExp javadoc grammar makes an escaped character a charexp and a range charexp - charexp, and requires escaping reserved characters "also in character classes".
  • 10.1.0 and java.util.regex read [\.-z] as a range. #14227 fixed [\] without a CHANGES entry; no test covers a dash after an escape.
  • Escaped range ends still work: [a-\~] is a range, [\!-\~] is not.
The full argument323 words

The contract, in its own words. These are the RegExp class javadoc lines at ea57360912ed; the same text is in 10.1.0.

  • RegExp.java:198-201: charclass ::= charexp - charexp, "(character range, including end-points)".
  • RegExp.java:335-336: charexp ::= ... | \ <Unicode character>, "(a single character)".
  • RegExp.java:342-345: "The reserved characters used in the (enabled) syntax must be escaped with backslash (\) or double-quotes ("..."). (In contrast to other regexp syntaxes, this is required also in character classes.)"

By this grammar [\.-z] is charexp('\.') - charexp('z'), a range. The escaped form is also the documented way to write a reserved range start.

History.

  • #14227 ("Fix failure found by TestOperations.testGetRandomAcceptedString", rmuir, approved by dweiss, merged 2025-02-13) fixed a lenient parse of [\].
  • Its commit message says: "When we consume an escape, either it matches the predefined special logic, or we emit range for next(), no other logic: it is an escape."
  • Its new tests cover [\-] (a literal dash), [] and [\]. None of them has a - after an escaped character.
  • CHANGES.txt has no #14227 entry, and MIGRATE.md says nothing about escapes or ranges in classes.
  • In #15182 (2025-09-12) a committer (rmuir) wrote that the syntax "should continue to match https://www.brics.dk/automaton/". The study found that the brics grammar has the same productions.

Tests. No Lucene test builds an escaped range start: TestRegExp.testCoreJavaParity never generates one. With fix option A, every test that was run still passes, including testEscapedDashCharClass: all of util.automaton, search.TestRegexp* and search.TestAutomatonQuery* (266), the queryparser module (533), and 96 tests in 7 other core classes that use RegExp. Tests in other modules that use it (highlighter, monitor, queries, memory, analysis) were not run with the fix.

An independent cross-model review (an independent reviewer from another model family, OpenAI GPT-5.6) was asked to refute the claim. It returned the verdict "real" with high confidence, and it rated the intended-behaviour argument "weak".

The devil's-advocate check405 words

The strongest case for "intended":

  • The author deliberately wrote "escaped character, don't parse it" and "no other logic: it is an escape".
  • testEscapedDashCharClass pins [\-] as a literal.
  • RegExp is @lucene.experimental (RegExp.java:352).
  • On 10.5.2 and main, [\.-] gives {., -}, as in java.util.regex; 10.1.0 throws on it.
  • Just before #14227, escaped range starts worked only by accident: after #14193 (fe42efc5918) rewrote the class parser into a loop, expandPreDefined consumed nothing for a non-letter such as ., so the next loop round re-read it through parseCharExp() and did the range check. #14227 replaced that fall-through, so it may have meant to remove it.

So an escape would make a character an opaque literal, and a dash after it would be a literal too.

Why it fails:

  1. "Emit range for next()" describes the one-character item for the escaped character itself. Neither the PR nor its tests mention a following -.
  2. The grammar in the same file still defines \ <char> as a charexp and a range as charexp - charexp, and it requires escaping reserved characters in classes. Under the "opaque" reading there would be no documented way to start a range at a reserved character.
  3. The parser is not consistent with that reading either. A range end still accepts an escape, so [a-\~] is a range while [\!-\~] is not.
  4. No CHANGES or MIGRATE entry announces a change of meaning, and the PR fixed a different input. Being experimental allows API changes, but this silently changed what an existing, documented pattern matches.
  5. Fix option A keeps [\-] a literal, and every existing test that was run with it passes (scope under "Tests").
  6. The accidental fall-through never reached a release: #14193 and #14227 were merged a week apart and both first released in 10.2.0. Every release up to 10.1.0 had an explicit range check for an escaped start, and #14227's tests and description concern [\], not a range.

A question a maintainer may ask. The grammar also lists \d, \s and \w as a charexp, yet [\d-z] is {0-9, -, z} on both 10.1.0 and main (executed). So charexp - charexp was never applied literally to predefined classes. That does not weaken the claim: the \ <Unicode character> production is explicitly "(a single character)", and it was a range start through 10.1.0.

Verdict: not intended; a regression.

Suggested fix

Recommended A: let an escaped character fall through to the shared range check

In parseCharClasses, send an escaped character that is not an ASCII letter or \ through the shared match('-') range check. All 15 pattern-table rows match 10.1.0; the repro, 266 automaton and regexp core tests and 533 queryparser tests pass (other modules not run). It also fixes LFV-022 (escaped characters skipping CASE_INSENSITIVE). [\.-] and [\~-!] throw again, as in 10.1.0.

All options and trade-offs3 options · 685 words

One fix was validated with the gradle build in a scratch worktree at ea57360912ed: option A, the study's idea, restructured. The others are design alternatives and were not run.

A: let an escaped character fall through to the shared range check (validated by the checks) (recommended)

In parseCharClasses, an escaped character that is not a letter or \ sets c = next() and then goes through the same match('-') range check, case-insensitive handling and single-character code as an unescaped one. The predefined-class branch ends with continue, which in a do-while goes to the loop condition, as before. The patch is +21/-22 lines in one method, mostly re-indentation.

do {
  int c;
  if (match('\\')) {
    if (peek("\\ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")) {
      expandPreDefined(starts, ends); // \d \s \w ..., \\, or throws for other letters
      continue;
    }
    c = next(); // escaped character: taken literally, but it can still start a range
  } else {
    c = parseCharExp();
  }
  if (match('-')) { /* range or CASE_INSENSITIVE_RANGE, unchanged */ }
  else if (check(ASCII_CASE_INSENSITIVE | CASE_INSENSITIVE)) { /* case forms, unchanged */ }
  else { /* single character, unchanged */ }
} while (more() && !peek("]"));

Trade-offs

  • Restores 10.1.0 behaviour: all 15 rows of the pattern table match 10.1.0 exactly.
  • Also fixes registry bug LFV-022 (CASE_INSENSITIVE skipped for an escaped character in a class), because escaped characters now take the shared path: its repro goes from 6 of 7 failing to 7 of 7 passing.
  • The same case-folding change applies under the deprecated ASCII_CASE_INSENSITIVE flag: the shared code checks ASCII_CASE_INSENSITIVE | CASE_INSENSITIVE (RegExp.java:1336), and the flag's javadoc says it now behaves like CASE_INSENSITIVE (418-425). It matters only for escaped characters with other case forms, in practice escaped non-ASCII letters such as \é, since escaped ASCII letters go to expandPreDefined. Not run with that flag.
  • Some patterns that 10.5.2 and main accept throw again, as in 10.1.0. [\.-] gives {., -} today and throws "expected ']' at position 5" with the fix, as [.-] already does on main. [\~-!] gives {!, -, ~} today and throws "invalid range" with the fix. This deserves a CHANGES line.
  • An escaped backslash as the start ([\\-z]) stays a non-range, as in 10.1.0; out of scope.

What the checks validated

  • TestRegExpEscapedRangeStart: 6 of 6 pass (stock: 6 tests, 4 failed).
  • :lucene:core:test --tests 'org.apache.lucene.util.automaton.*' --tests 'org.apache.lucene.search.TestRegexp*' --tests 'org.apache.lucene.search.TestAutomatonQuery*': 266 tests, 0 failures, 2 skipped. Stock gives 266 tests and 4 failures, all in the repro class.
  • :lucene:queryparser:test: 533 tests, 0 failures, 1 skipped (same as stock).
  • 7 other core classes that use RegExp or RegexpQuery (TestTermsEnum, TestTermsEnum2, TestDocValuesRewriteMethod, TestFieldCacheRewriteMethod, TestDisjunctionMaxQuery, TestMatchesIterator, TestQueryBuilder), through the repro runner: 96 tests, 0 failures (run by the fact check).
  • TestRegExp* and TestRegexp* with seed 1234ABCD: 89 tests pass (the log records the seed only; the command line was not saved).
  • TestRegExpParsing with the two new tests: 65 of 65 pass (stock: the 2 new tests fail).
  • :lucene:core:checkGoogleJavaFormat passes.
  • Through the repro runner (--shadow-src): the repro passes 6 of 6 with -ea and -da, the LFV-022 repro passes 7 of 7, and the pattern table is identical to 10.1.0.
  • Not run with the fix: tests in other modules that use RegExp or RegexpQuery (highlighter, monitor, queries, memory, analysis).

B: check for a dash inside the escaped branch only

Copy the match('-') range and CASE_INSENSITIVE_RANGE handling into the else branch at RegExp.java:1318-1323.

Trade-offs

The smallest behavioural change: escaped single characters still skip case folding, so LFV-022 stays open. It duplicates the range code in two places. Not run.

C: keep the 10.2+ behaviour and document it (not recommended)

Amend the javadoc to say that an escaped character cannot start a range.

Trade-offs

It contradicts the grammar and the escaping rule in the same javadoc, and the brics grammar a committer wants to keep. It leaves no documented way to start a range at a reserved character, and it keeps the silent change for everyone who upgraded from 10.1 or earlier.

Downstream note

A pattern that reaches RegExp (from an application, an administrator's allow or deny patterns, or an end user's query) can match a different set than written; a deny or exclusion pattern that matches less widens what is allowed. No affected real pattern is known. Lucene's SECURITY.md leaves untrusted input to the application, so this is normally a correctness bug.

How it was found

Differential fuzzing with Jazzer: an AI tool ran a target that renders random regex syntax trees both as a Lucene RegExp and as a java.util.regex pattern and compares the languages (about 145 k executions). An independent AI verifier wrote its own tests and bisected the change to #14227 (19be0e5fd11).

The method in full166 words

Method: differential fuzzing (Jazzer) against java.util.regex, the study's non-formal baseline tier, 2026-10-22, main 7cecf79523.

  • An AI tool ran Jazzer 0.30.0 on a target, RegexDiffFuzzer, that renders one random regex syntax tree both as a Lucene RegExp and as a java.util.regex pattern and compares the two languages. About 145 k executions in 5 runs produced this bug, LFV-022 and two automaton findings.
  • A separate, independent AI verifier read the candidate's repro only for its input, wrote its own tests (6 of 9 fail; the 3 controls pass) and an ASCII differential. It bisected the change at commit level to 19be0e5fd11 (#14227) and corrected the candidate's first guess, #14193, which still parses [\.-z] as a range.
  • On 2026-10-25 the hunt re-ran the repro on ea57360912ed (-ea and -da) and on 10.5.2, 10.2.0 and 10.1.0, built the full release matrix and the A/B commit pairs, searched for prior reports, and obtained the cross-model refute review. For this page, the fix was validated with gradle.

Prior reports, history and checks

Prior reports

No report found in GitHub issues and PRs, commits, the mailing lists, the JIRA archive or the web (152 tool queries plus manual searches, 2026-10-25), and upstream has not fixed it. The literal [\.-z] cannot be searched on GitHub.

Searches5 searches

No report of this bug was found, and upstream has not fixed it. GitHub search drops punctuation, so the literal [\.-z] cannot be searched, and downstream users could have hit it without recognising it. So finding nothing does not prove that nobody was affected.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), via a scripted prior-report search2026-10-2571 queries: RegExp, RegexpQuery, parseCharClasses, expandPreDefined, parseCharExp; escaped range, character range, backslash, hyphen, dash, "expected ']'"; everything about regex or character classes created after 2025-02-12; testEscapedDashCharClass; references to 14227no report; the closest items are the cause #14227, #14493 (toString escaping), #11678 (open, \n and \t as classes), #14193, #14224, #14378/#14381, #10410 and #15182
Commits, downstream, mailing lists, JIRA, web (same tool)2026-10-253 commit queries, 2 downstream, 48 on dev@, java-user@, issues@ and commits@, 10 in the JIRA archive, 18 web; 152 tool queries in total, 0 errorsnothing about this bug
Manual GitHub searches2026-10-2517 on apache/lucene (all open regex issues and PRs, recent updates, unmerged PRs since 2025-02-12); 10 on two downstream search-engine trackers and apache/solrunrelated items only
lists.apache.org2026-10-2516 queries over 2025-02-01 to 2026-10-25 on dev@, java-user@, users@ and issues@release threads and mirrors of the GitHub items; no report
Open PRs and branches2026-10-25changed files of all 412 open apache/lucene PRs; RegExp.java history on main, branch_10x, branch_10_6 and branch_10_5; CHANGES.txt and MIGRATE.md at ea57360912edonly #14389 and #14350 touch RegExp.java, both in case-folding code; no fix; no CHANGES entry for #14227

History

Introduced by #14227 (19be0e5fd11, merged 2025-02-13, backported to 10.x the same day): every release from 10.2.0 to 10.5.2 and main fail the repro, while 9.12.2, 9.12.3, 10.0.0 and 10.1.0 pass. Bisected on both lines; reverting only the #14227 hunk in 10.2.0 makes it pass.

Versions tested7 rows

The behaviour changed in #14227, merged on main as 19be0e5fd11 on 2025-02-13. It was backported to the 10.x line the same day as 7682ef096e2, and git tag --contains 7682ef096e2 lists exactly 10.2.0 through 10.6.0. Every row below ran the same 6-test JUnit repro (2 controls plus 4 checks) on SHA-1-checked Maven Central jars, seed DEADBEEF, with assertions on. The 9.12.x rows use a copy that differs in one line (Operations.sameLanguage, because the 9.x test framework has no AutomatonTestUtil.sameLanguage). The commit rows compiled that commit's util/automaton package over the 10.2.0 jars, and the fix row is main with fix option A.

Nothing has changed the branch since: on main, git blame still assigns RegExp.java:1315-1323 to 19be0e5fd11.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affected4 of 6 fail, with -ea and -da; gradle: 6 tests, 4 failed
10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe same 4 of 6 fail (10.5.2 also with -da)
9.12.2, 9.12.3, 10.0.0, 10.1.0 not affected6 of 6 pass
10.x line: 57c30f9b84b (parent) vs 7682ef096e2 (#14227 backport) bisectthe parent passes 6 of 6; the backport fails 4 of 6
main: 33f314f3e95 (parent) vs 19be0e5fd11 (#14227) bisectthe parent passes 6 of 6; #14227 fails 4 of 6
10.2.0 with only the #14227 hunk reverted revert check6 of 6 pass
main ea57360912ed with fix option A fix6 of 6 pass (gradle and the repro runner, -ea and -da)

Independent checks

An independent verifier and re-runs on two more main commits reproduced it, and a cross-model refute review rated it real with high confidence. Fact checks (one cross-model) found it sound; their wording fixes were applied, and the diagram check passed.

Checks and verdicts528 words

Each check worked from the code or its own runs. The study found and verified the bug, and the hunt re-verified it on the current main, reviewed it across model families, and dated and searched it.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (fuzzing/PBT) new bug, regressionIts own 9 tests: 6 fail, 3 controls pass. Bisected to 19be0e5fd11 and corrected the first guess (#14193). Showed that [\!--] throws.
Public-list rerun reproduces on b83101cdd29 tests, 6 failures, same tests as the study.
Hunt verification realMain ea57360912ed fails with -ea and -da; 10.5.2 and 10.2.0 fail; 10.1.0 passes. The differential finds 1,852 + 13 of 1,867 on main and 0 on 10.1.0.
Cross-model refute review real, high confidenceAn independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own [\+-0] test through the repro runner: it fails on main (-ea, -da, and seed BADC0DE three times) and passes on 10.1.0 and with the fix. The repro driver re-ran the test independently, also on 10.5.2, with the same result. The reviewer rated the intended-behaviour argument weak.
History follow-up introduced by #14227, first released in 10.2.0Ran the full release matrix and A/B pairs on both lines, plus a revert inside 10.2.0.
Prior-report follow-up new152 tool queries plus manual searches; nothing found.
This page's own runs fix validatedGradle in a scratch worktree: stock fails 4 of 6, the fix passes, and 266 core and 533 queryparser tests pass. The pattern table was run on 10.1.0, 10.5.2, main and main with the fix. Classic QueryParser /[\.-z]/ gives 3 instead of 5 on ea57360912ed.
Correction to an input one claim correctedThe prior-report record says that [0-9] has printed as [\0-\9] since #14493. 10.1.0, which predates #14493, prints [\0-\9] too, so only the re-parse of that output broke, not its escaping.
Independent fact check real; 10 wording fixes, appliedA separate AI tool checked every file:line, commit, count and quote, and re-ran the repro, the snippet, the pattern table, the differential and fix option A. It found two overclaims (the differential's scope, the test scope of the fix) and eight smaller inaccuracies; none affects whether the bug is valid. It also ran the escaped space and non-ASCII starts and 96 more core tests with the fix.
Cross-model fact check factually sound; 8 items, appliedAn independent reviewer from another model family (OpenAI GPT-5.6) checked the brief against the code and the git history. It found the same two scope problems, a wrong ancestor statement for #14193 on the 10.x line, an overstated security sentence, and three missing caveats (the workaround and the javadoc escaping rule, the accidental fall-through after #14193, and ASCII_CASE_INSENSITIVE under the fix). It rated the intended-behaviour argument weak.
Diagram check passEvery value, line number, code line and order of events in the PNG matched the code and executed runs, several re-run independently. Its polish suggestions (the period glyph in tiles, the contrast of the code-point labels and the excerpt footnote, the card's "no error" label, the footnote's wording) were applied and the diagrams re-rendered.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes480 words

Finding record

LFV-019, snapshot of 2026-11-06

Registry title
RegExp no longer parses an escaped range start such as [\.-z] as a range
Area
util / automaton
Priority
Medium, rank 37: A default-syntax RegExp regression since 10.2 silently changes what a character-class range starting with an escaped character such as [\.-z] matches (an Elasticsearch role pattern could widen access), with a cheap parser fix, but such ranges are rare.
Impact class
silent wrong results
Impact note
Anyone whose regular expression starts a character-class range with an escaped character (RegexpQuery, RegExp, policy patterns written as Lucene regexes); default syntax; released since 10.2.0.
Summary
You write a RegexpQuery or RegExp whose character class starts a range with an escaped character, such as [\.-z]. You expect it to match the range from '.' to 'z', as in 10.1 and java.util.regex. Instead, since 10.2.0, it is parsed as the three characters '.', '-' and 'z', so the query silently matches different terms.
Introduced
19be0e5fd11 (#14227), commit-level bisect (2025-02-13)
First affected version
10.2.0
Affected versions
10.2.0, 10.5.2, 10.6.0 and main e1faeba08bd wrong; 9.12.2 and 10.1.0 correct [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal baseline: fuzzing/PBT
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-25): the JUnit repro through the repro runner with -ea and -da, and through gradle in a scratch worktree, stock and with fix option A (the worktree was removed after the runs). Also the Maven Central jars 9.12.2, 9.12.3, 10.0.0, 10.1.0 and every release from 10.2.0 to 10.5.2 (SHA-1 checked), and the introducing commit and its parent on main and on the 10.x line. The study reproduced it on 7cecf79523 and the public-list rerun on b83101cdd2; RegExp.java is byte-identical at all three main commits.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java at ea57360912ed (lines 1297-1361, 1502-1505)
  • a Lucene clone tag releases/lucene/10.1.0: RegExp.java (lines 1203-1212, 1234-1255)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java at ea57360912ed (lines 54-55, 98)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java at ea57360912ed (lines 198-201, 335-336, 342-345, 352, 1318-1323)
  • lucene/CHANGES.txt at ea57360912ed (line 1933: GITHUB#14193; no GITHUB#14227)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java at ea57360912ed (lines 418-425: ASCII_CASE_INSENSITIVE; 1336: the shared case check)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagram check compared every element on screen, and this alt text, with the code and executed runs.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.