All findings · Lucene finding · LFV-813 · regexp-interval-plus-sign-bound-changes-width

RegExp numeric interval accepts a '+' on a bound and counts it as a digit

A + on a numeric-interval bound is neither rejected nor ignored but counted as a digit: RegexpQuery <+1-+9> matches "05" and not "5", the meaning of <01-09>, and <+100-999> loses its fixed width. Unsigned bounds work; every release since 4.0.0-ALPHA does this on Java 7 or later.

Summary

Priority
Low, rank 920An undocumented '+' on a RegExp interval bound silently changes the matched language, but only with a contrived spelling.
Impact
minor
Since
4.0.0-ALPHA (2012), on Java 7+ runtimesintroduced 2010-04-06 · over 16 years
Affected
4.0.0-ALPHA to 10.6.0 and main
Status
documented
Found by
bounded-exhaustive check against the javadoc

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

The bound's text goes to Integer.parseInt, which accepts "+1" as 1, and the fixed width is the length of that text, so the + counts as one more digit.

Background: numeric intervals in Lucene regexps

  1. Lucene's RegExp syntax, used by RegexpQuery and the query parsers' /.../ syntax, has an optional numeric interval <n-m> that matches the numbers from n to m written as text. It is on by default.
  2. When both bounds have the same number of digits, every match has exactly that many characters: <1-9> matches "1" to "9", <01-09> matches "01" to "09". When they differ, any number of leading zeros is allowed.
Diagram of the bug. You write the regexp <+1-+9> and expect an interval syntax error or the numbers 1 to 9, as <1-9> gives. Inside Lucene main, the bound texts "+1" and "+9" go to Integer.parseInt, which accepts the plus sign and returns 1 and 9, while the width is taken from the text length, 2. The interval is built as <01-09>: it matches "05" and not "5". A plus on one bound only, as in <+100-999>, drops the fixed width, so "0500" matches. Fix direction: accept only the digits 0-9 in a bound.
How <+1-+9> is parsed on main: the bound text "+1" is read as 1 by Integer.parseInt but counted as two characters for the width, so the interval becomes <01-09>, compared with <1-9>. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index the terms "5" and "05" and search with RegexpQuery <1-9> (or type /<1-9>/ into the classic QueryParser): you get "5". Bounds with the same number of digits fix the length, so <01-09> would give "05".
  2. Write the same range with plus signs, <+1-+9>. You expect interval syntax error, which <-1-9>, < 1-9> and <1-9x> give, or the meaning of <1-9>.
  3. You get no error and only "05". Each bound's text goes to Integer.parseInt, which accepts a leading + (since Java 7), and the width is the text length: "+1" is 2 characters, so Lucene builds <01-09>.
  4. With a + on one bound the two lengths differ and the fixed width is dropped: <+100-999> also matches "0500" and "00500", which <100-999> rejects. <+100-+999> means "0100" to "0999".

Who is affected

Anyone whose regexp has a + on an interval bound, typed into a query parser or built by an application from signed number text. Interval syntax is on by default. The query silently matches a different set of terms; nothing else breaks, unsigned bounds are exhaustively correct, and nothing new can be expressed. Rare: no report since 2011.

Mechanism, terms and numbers562 words

Lucene's RegExp has an optional numeric-interval syntax <n-m>, on by default in RegexpQuery, the query parsers' /.../ syntax, Intervals.regexp and RegexCompletionQuery. Its javadoc says the bounds are "non-negative decimal integers" and that bounds with the same number of digits fix the length of the matching strings: <1-9> matches "1" to "9", <01-09> matches "01" to "09".

A bound written with a leading + is accepted without error and the + is counted as a digit. The parser passes each bound's text to Integer.parseInt, which accepts a leading + since Java 7, and takes the width from the raw text length. So <+1-+9> is built as <01-09>: a RegexpQuery or a classic QueryParser query /<+1-+9>/ returns the term "05" instead of "5". A + on one bound only makes the lengths differ, which drops the fixed width: <+100-999> also matches "0500". Every other malformed bound tried (-1, spaces, trailing text, hex, overflow) is rejected with interval syntax error. Unsigned bounds are exhaustively correct, and every + form means something an unsigned spelling can also say, so the only effect is a silently different match set for the author's own query. It has been in every release since 4.0.0-ALPHA, observable on Java 7 or later. Requiring ASCII digits before Integer.parseInt fixes it; it passes the repro, the closest suites and :lucene:core:check -x test.

The bug, step by step (main 645d4fe14a20, RegExp.java)

  1. The bound texts. For <...> with a - (:1460-1465), a - at the start, at the end or twice is rejected (:1474), then String smin = s.substring(0, i); String smax = s.substring(i + 1); (:1476-1477).
  2. Integer.parseInt accepts a +. int imin = Integer.parseInt(smin); int imax = Integer.parseInt(smax); (:1478-1479). Its javadoc allows "an ASCII plus sign '+' ... to indicate a positive value" (parseInt("+42", 10) returns 42); that is true since Java 7 (JDK-5017980).
  3. The width is the text length. if (smin.length() == smax.length()) digits = smin.length(); else digits = 0; (:1480-1482), so the + is counted.
  4. Built as a fixed-width interval. makeInterval(flags, imin, imax, digits) (:1488) becomes Automata.makeDecimalInterval(min, max, digits) (:728-729), where digits > 0 means a fixed number of digits and 0 means any number of leading zeros (Automata.java:490-501).
  5. Every other bad bound fails. Any NumberFormatException becomes IllegalArgumentException("interval syntax error at position ...") (:1489-1490).

Worked through (executed on main)

regexpsmin, smaxdigitsbuilt asmatches
<1-9>1, 91(1, 9, 1)1 to 9
<+1-+9>+1, +92(1, 9, 2) = <01-09>01 to 09 only
<+1-9>+1, 90(1, 9, 0)1 to 9 with any number of leading zeros (01, 005)
<+100-999>+100, 9990(100, 999, 0)100 to 999 with any leading zeros (0500, 00500)
<+100-+999>+100, +9994(100, 999, 4) = <0100-0999>0100 to 0999 only

Over all 111,111 digit strings of length 0 to 5, <+1-+9> differs from <1-9> on 18 strings, <+1-9> on 36, and <+100-999>, <100-+999> and <+100-+999> each differ from <100-999> on 1,800. + is the only non-digit that gets through: <-1-9>, < 1-9>, <1 -9>, <1-9 >, <1-9x>, <0x1-9>, <1_0-20>, <1.0-2>, <99999999999-1>, <++1-9>, <+-1-9>, <1--9>, <+-9> and <1-+> are all rejected.

Exposure and likelihood225 words
  • Who is hit. A regular expression with a numeric-interval bound written with a leading +: typed by a user into a query parser, or built by an application such as "<" + lo + "-" + hi + ">" from text that carries a sign (a %+d format or a user field; Integer.toString never adds a +).
  • Default reach. RegexpQuery(Term) ("By default, all regular expression features are enabled"), the classic QueryParser and the flexible StandardQueryParser (RegExp.ALL), Intervals.regexp (default flags) and RegexCompletionQuery(Term, BitsProducer) (RegExp.ALL). The classic QueryParser path was run: /<+1-+9>/ matches "05" and not "5".
  • What goes wrong. No exception, and a different match set: with both bounds signed the intended values are missed and zero-padded ones match; with one bound signed any number of leading zeros is accepted.
  • Limits. Deterministic, any index size, no setting. The study's bounded-exhaustive run (every <n-m> with n, m up to 1100, leading zeros and optional +, 44.4 million parses) found 0 mismatches for unsigned bounds. Every + language equals a documented spelling (<+1-+9> = <01-09>, <+100-999> = <0100-999>; 36,905 checks), and the automaton stays at most 14 states.
  • Likelihood: rare. It needs a + in a bound; then it happens every time. No report since Java 7 made it reachable in 2011. RegExp and RegexpQuery are @lucene.experimental.

Minimal repro

One JUnit test in lucene/core, public API only: it parses <+1-+9> and passes if it is rejected with IllegalArgumentException or matches "5" like <1-9>. No index, deterministic; fails with assertions on and off.

TestRegExpIntervalPlusSign.java (1 test)40 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 TestRegExpIntervalPlusSign extends LuceneTestCase {

  public void testPlusSignOnIntervalBounds() {
    // <+1-+9> must be rejected or, like <1-9>, match "5"; instead the '+' counts as a digit (<01-09>)
    RegExp re;
    try {
      re = new RegExp("<+1-+9>");
    } catch (IllegalArgumentException rejected) {
      return;
    }
    CharacterRunAutomaton run =
        new CharacterRunAutomaton(
            Operations.determinize(re.toAutomaton(), Operations.DEFAULT_DETERMINIZE_WORK_LIMIT));
    assertTrue(
        "expected <+1-+9> to match \"5\" like <1-9>, but it does not (matches \"05\": "
            + run.run("05")
            + ")",
        run.run("5"));
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same RegExp.java)
cp $REPRO/TestRegExpIntervalPlusSign.java lucene/core/src/test/org/apache/lucene/util/automaton/
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestRegExpIntervalPlusSign -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
<+1-+9> is rejected with IllegalArgumentException, or matches "5" like <1-9>.
Observed
Accepted; it matches "05" but not "5" (the language of <01-09>), on main with assertions on and off, and on 10.6.0 and 10.5.2.

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

More files, environment, full output347 words

The test sits in the automaton test package, as Lucene's own automaton tests do, but uses no package-private member. It accepts either fix a maintainer may choose (reject the +, or read "+1" as 1): of the three readings (rejected, <1-9>, today's <01-09>) it fails only on today's. It checks "5" only; it does not assert that "05" is rejected.

As the regression test it needs two lint edits: annotate the unused catch parameter (Lucene's ecj settings make it an error, gradle/validation/ecj-lint/ecj.javadocs.prefs:136) in the layout google-java-format requires, as QueryParserBase.java:803-805 does, and shorten the 102-character comment line to at most 100:

    // <+1-+9> must be rejected or, like <1-9>, match "5"; today the '+' counts as a digit
    RegExp re;
    try {
      re = new RegExp("<+1-+9>");
    } catch (
        @SuppressWarnings("unused")
        IllegalArgumentException rejected) {
      return;
    }

With those edits it fails on stock, passes with the recommended fix, and :lucene:core:check -x test passes with it in place. The one-bound width loss (<+100-999>) and <+100-+999> share the root cause and the fix; they are left out of the test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 and 10.5.2 release jars with this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestRegExpIntervalPlusSign > testPlusSignOnIntervalBounds FAILED
    java.lang.AssertionError: expected <+1-+9> to match "5" like <1-9>, but it does not (matches "05": true)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8022A4C09432B805]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.util.automaton.TestRegExpIntervalPlusSign.testPlusSignOnIntervalBounds(TestRegExpIntervalPlusSign.java:34)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same message with -Ptests.asserts=false, and on the 10.6.0 and 10.5.2 release jars

# probe on the same main, assertions off ("matches" = which of 16 probe strings are accepted):
<1-9>              ACCEPTED toString=<1-9> digits=1 matches={1,5,9}
<01-09>            ACCEPTED toString=<01-09> digits=2 matches={01,05,09}
<+1-+9>            ACCEPTED toString=<01-09> digits=2 matches={01,05,09}
<+1-9>             ACCEPTED toString=<1-9> digits=0 matches={1,5,9,01,05,09,001,005}
<100-999>          ACCEPTED toString=<100-999> digits=3 matches={100,500,999}
<+100-999>         ACCEPTED toString=<100-999> digits=0 matches={100,500,0500,00500,999,0100,0999}
<+100-+999>        ACCEPTED toString=<0100-0999> digits=4 matches={0500,0100,0999}
<-1-9>             REJECTED IllegalArgumentException: interval syntax error at position 5
<1-9x>             REJECTED IllegalArgumentException: interval syntax error at position 5
a{+2}              REJECTED IllegalArgumentException: integer expected at position 2
QueryParser /<1-9>/ -> RegexpQuery f:/<1-9>/ matches 5=true 05=false
QueryParser /<+1-+9>/ -> RegexpQuery f:/<+1-+9>/ matches 5=false 05=true

Why it looks unintended

  • The javadoc fixes the width by "the same number of digits", and + is not a digit: read as 1, "+1" must give <1-9>; read as invalid, it must be rejected.
  • The parser rejects every other malformed bound tried, and upstream tests assert that truncated and empty intervals throw.
  • The 2010 code predates Java 7, whose Integer.parseInt began accepting +.
The full argument262 words
  • The documented contract. "Numerical intervals are specified by non-negative decimal integers and include both end points, and if n and m have the same number of digits, then the conforming strings must have that length (i.e. prefixed by 0's)." (RegExp.java:347-350). Under neither reading of "+1" is <01-09> right.
  • The block validates bounds. It rejects a misplaced - (:1474) and turns every NumberFormatException into interval syntax error (:1489-1490); the constructors document @exception IllegalArgumentException if an error occurred while parsing the regular expression (:510, :521, :533). 14 other malformed spellings are rejected.
  • The sibling syntax is ASCII-digits-only. {n,m} scans peek("0123456789") (:1272-1279); a{+2} fails with integer expected at position 2.
  • Tests. testTruncatedInterval (<1-), testTruncatedInterval2 (<1) and testEmptyInterval (<->) expect IllegalArgumentException (TestRegExpParsing.java:491-513); <1- and <1 fail at the > check (:1463), <-> at the bound check (:1474); no test covers a malformed bound spelling such as <1-9x>, and every interval in the tests is unsigned.
  • Never designed. The parse lines came with the 2010 port of dk.brics.automaton (b679816a7001). On Java 6 Integer.parseInt("+1") threw, so <+1-+9> was an interval syntax error; JDK-5017980 (Java 7, 2011) widened parseInt afterwards (read; no Java 6 run).
  • Precedent. #10410 (LUCENE-9370, type:bug): "The RegExp class is too lenient in parsing user input which can confuse or mislead users", fixed in 9.0 (its CHANGES entry is under Improvements). PR #12277 made a{99,11} throw: "we catch an invalid usage and throw a clean exception in that case".
The devil's-advocate check162 words

The strongest case (reading-only cross-model reviews said not real on it): + is outside the documented <n-m> grammar of an @lucene.experimental class, so what it parses to is unspecified; every documented input is exhaustively correct; garbage in, garbage out.

Why it fails: the parser does not treat out-of-grammar bounds as unspecified; it rejects all the others tried, and its tests assert that truncated and empty intervals throw. The + gets through only because of a later JDK change, and the meaning it gets contradicts the digit-count rule even under the lenient reading. The root-cause reviewer graded its works-as-designed argument "strong", but still wrote: "The case is not conclusive because the interval documentation never formally defines the lexical production for n and m, and counting an accepted plus sign as a digit conflicts with the stated width rule." It is a good reason for low priority, and maintainers may file the fix as an improvement; it does not make <+1-+9> = <01-09> correct.

Suggested fix

Recommended Bounds must be ASCII digits (reject everything else)

Require each bound to be ASCII digits 0-9 before Integer.parseInt, else throw the existing interval syntax error. Validated on main: the repro passes with assertions on and off, the automaton, regexp, query parser, intervals and regex-completion tests pass, and :lucene:core:check -x test passes. It also rejects non-ASCII digit bounds; rejecting only a leading + is the narrower alternative.

All options and trade-offs3 options · 593 words

Both reject variants were run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. "Closest suites" means :lucene:core:test --tests 'org.apache.lucene.util.automaton.*' plus TestRegexpQuery, TestRegexpRandom, TestRegexpRandom2, TestAutomatonQuery and TestAutomatonQueryUnicode. The choice is the maintainers': reject the + (A or B), or read "+1" as 1 with width 1 (C, not run); the test accepts all three.

Bounds must be ASCII digits (reject everything else) (validated by the checks) (recommended)

In RegExp.parseSimpleExp, after smin and smax are cut (:1476-1477), throw the existing NumberFormatException (turned into interval syntax error) unless both are non-empty runs of 0-9; a small isAsciiDigits helper, +15 lines. It matches the {n,m} syntax, which reads ASCII digits only, and makes "digits" in the width rule exact.

+  private static boolean isAsciiDigits(String s) {
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
+      if (c < '0' || c > '9') {
+        return false;
+      }
+    }
+    return s.isEmpty() == false;
+  }
@@ parseSimpleExp, interval branch
           String smin = s.substring(0, i);
           String smax = s.substring(i + 1);
+          // bounds are non-negative decimal integers written with the ASCII digits 0-9 only
+          // (Integer.parseInt alone would also accept a leading '+' and non-ASCII digits)
+          if (isAsciiDigits(smin) == false || isAsciiDigits(smax) == false) {
+            throw new NumberFormatException();
+          }
           int imin = Integer.parseInt(smin);

Trade-offs

  • It also rejects non-ASCII decimal digit bounds, which main reads as ASCII values: <١-٩> (Arabic-Indic) and <1-9> (full-width) are accepted as <1-9> on main and rejected with this fix. That reading is defensible and not claimed as a bug, so it is a behaviour change worth a CHANGES entry; a javadoc tweak ("written with the digits 0-9") would document it.
  • A query that uses a + bound today gets an exception instead of a different match set.

What the checks validated

  • Stock 645d4fe14a20: the repro fails with assertions on and off.
  • With the fix: the repro passes with assertions on and off; the closest suites pass (261 tests including the repro, 2 skipped); queryparser classic.* and flexible.standard.* (222 tests, 1 skipped), queries intervals.* (117) and TestRegexCompletionQuery (5) pass; :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs, license checks) passes with the lint-ready regression test in place, which passes with the fix and fails on stock.
  • Probe: the five + forms are rejected with interval syntax error; the 14 other malformed spellings stay rejected; <1-9>, <01-09> and <100-999> keep their languages.

Reject only a leading + (validated by the checks)

Four lines after :1477: if (smin.charAt(0) == '+' || smax.charAt(0) == '+') throw new NumberFormatException(); (both are non-empty there, :1474). The narrowest change: non-ASCII digit bounds keep today's reading.

          // Integer.parseInt accepts a leading '+', which is not a digit of a bound
          if (smin.charAt(0) == '+' || smax.charAt(0) == '+') {
            throw new NumberFormatException();
          }

Trade-offs

  • The bound check still relies on Integer.parseInt's grammar, which is wider than the documented one.

What the checks validated

  • The repro passes; the closest suites pass (261 tests including the repro, 2 skipped); the probe gives the same rejections as the recommended fix, and non-ASCII digit bounds stay accepted as <1-9>.

Read "+1" as 1 and count only digits (not run)

Drop one leading + from each bound before the length check (still rejecting ++1), so <+1-+9> means <1-9>. It satisfies the test, but keeps accepting a spelling no documentation mentions, while {n,m} rejects a{+2}. Not run.

Downstream note

Downstream, an application that builds <lo-hi> range filters from signed number text (for example %+d output) would silently filter on a different range; pass digits only.

How it was found

A bounded-exhaustive check parsed every interval <n-m> with n and m up to 1100, up to two leading zeros and an optional + on each bound (44.4 million parses), with the real jar and compared each result with a reference written from the javadoc. Unsigned bounds had 0 mismatches; 14,647,338 + parses got another meaning.

The method in full170 words

Method: semi-formal (the finder's label): bounded-exhaustive translation validation of the real main jar against the javadoc, not a proof.

  • The unit's plan predicted the + signal by reading the Integer.parseInt lines; the exhaustive run witnessed and measured it.
  • Harness h297.IntervalTV: every <n-m> text with 0 <= n, m <= 1100, up to two leading zeros and an optional + on each bound, both orders, against a reference built from the javadoc rules; for n, m <= 120 an exact language comparison (product of the determinized Lucene automaton with the reference's minimal DFA).
  • 195.3 million checks in seven legs with 0 in-domain mismatches (documented input); 35 of 35 mutants of the checker were killed, and a RegExp mutant that drops the fixed width gives 99,438 mismatches, so the check can see width errors.
  • An independent reproducer then wrote its own public-API test comparing each signed form with its unsigned form over all 111,111 digit strings of length 0 to 5, and ran RegexpQuery and the classic QueryParser on an index.

Prior reports, history and checks

Prior reports

No prior report found: 81 tool queries and 57 manual searches over GitHub issues and PRs, the JIRA archive, the mailing lists, downstream projects, the origin library and the web, and the changed files of all 410 open PRs (2026-10-31). Not fixed upstream.

Searches5 searches

Verdict new. The closest item is #12251 (open since 2023), which quotes this interval block as the example of correct exception handling while reporting {n,m} parsing. Its fix PR #12277 and #10410 / LUCENE-9370 are precedents for making RegExp stricter. GitHub and list searches match words, so they cannot match the literal <+1-+9>; private discussions cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), JIRA archive, mailing lists, web2026-10-31a scripted prior-report search, 81 queries: 40 GitHub issue/PR, 3 commit, 2 downstream, 24 mailing-list (dev@, java-user@, issues@, commits@), 6 JIRA, 6 web505 distinct hits, 0 high overlap; every medium hit read, none about interval bounds
GitHub, manual2026-10-3116 apache/lucene searches (numeric/numerical/decimal interval, leading zeros, sign, Integer.parseInt, "interval syntax error", makeDecimalInterval, INTERVAL flag); 12 on Elasticsearch, OpenSearch, Solr, Lucene.NET, dk.brics.automaton and GitHub-wide; 4 open-PR listingsclosest: #12251 / #12277 (repeat parsing), #10410 (backslash leniency), #5373 (repeat messages)
Mailing lists, JIRA archive and web, manual2026-10-3112 list-archive queries on dev@, java-user@ and issues@; 6 JQL queries on LUCENE, SOLR and LUCENENET; 7 web queries0 hits or noise; documentation and Q&A pages only
Open PRs and upstream branches2026-10-31changed files of all 410 open apache/lucene PRs; RegExp.java on main 2f424fd428da, branch_11x and branch_10_6only #14389 and #14350 touch RegExp.java (case folding); the same block on all three branches
Origin library dk.brics.automaton2026-10-31issues, PRs, commits, master RegExp.javasame parsing code, no report; 43fbff6420 fixed interval printing only

History

Since the 2010 port of dk.brics.automaton (b679816a7001), first released in 4.0.0-ALPHA (2012); observable on Java 7 or later, which made Integer.parseInt accept +. 15 releases from 4.0.0-ALPHA to 10.6.0, and main, were run and fail the same way. Never fixed.

Versions tested10 rows

Introduced by b679816a7001 (2010-04-06, svn r931278, "LUCENE-2370: Reintegrate flex_1458 branch into trunk (revision 931101)"), the LUCENE-1606 port, with the same parseInt and width lines; no GitHub PR (SVN era).

Runtime condition. On Java 6 Integer.parseInt("+1") threw, so the same code rejected <+1-+9>; JDK-5017980 (fix version 7, resolved 2011-05-17) changed that. 4.0.0-ALPHA to 4.7.2 were built for Java 6 and are affected only on Java 7+ runtimes; 4.8.0 (Java 7 minimum) is the first release affected everywhere.

Not a regression. git log -L over the <...> branch (:1460-1491) finds five later edits, none changing what a bound means: ba56e757045 (2014, LUCENE-6046, b becomes originalString), 887fe4c83d4 (2020, LUCENE-9386, adds flags), 2d6ad2fee6d (2020, LUCENE-9570, reformatting), 2971f311a2b (2021, LUCENE-9911, the exception gets its cause) and fb8183332bc (2023, #12462, substring(i + 1)). The same block is in all 124 release tags from 4.0.0-ALPHA to 10.6.0 in the apache/lucene history (8.8.2 to 8.11.x were tagged in the older lucene-solr repository; 8.11.4 was run), upstream main 2f424fd428da and branch_10_6.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0, 10.5.2, 9.12.3 affectedthe minimal test fails (release jars, assertions on and off; 9.12.3 with assertions off in the independent fact check)
10.0.0, 9.0.0, 8.11.4, 8.0.0, 7.0.0, 6.0.0, 5.0.0 affectedthe history repro fails the same way (18, 36 and 1,800 differing digit strings)
4.10.4, 4.8.0 4.8.0: first on every runtimethe history repro fails the same way
4.7.2, 4.0.0 affectedthe history repro fails the same way (Java 7+ runtimes)
4.0.0-ALPHA first affectedfails on OpenJDK 25 and OpenJDK 11
4.0.0-ALPHA to 4.7.2 on Java 6 read, not runInteger.parseInt("+1") threw, so <+1-+9> was an interval syntax error
3.6.2, 3.0.1 API absentno automaton package
4.0.0-ALPHA to 10.6.0, all 124 tags readthe same parse block (tags in the apache/lucene history; 8.8.2 to 8.11.x were tagged in lucene-solr, 8.11.4 was run)
fixes on 645d4fe14a20 not affectedASCII-digits and reject-+ fixes: <+1-+9> is rejected; the repro and the closest suites pass

Independent checks

An independent reproducer, a devil's advocate, an adjudication and a later tie-break rated it real for the + half; cross-model reviews (OpenAI GPT-5.6, reading only) split, one real and three not real on out-of-grammar input, all agreeing on the root cause. It still reproduces on main and 10.6.0.

Checks and verdicts262 words

There is no executing cross-model review: every review by the other model family read the code only. The non-ASCII-digit half the finder also reported was excluded as defensible by the adjudication, the verifiers and the cross-model lenses.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (bounded-exhaustive check) candidate0 mismatches for unsigned bounds; 14,647,338 + parses with another meaning; public-API JUnit fails on main, 10.5.2 and 9.12.3.
Independent reproducer real (+ half)Own test over 111,111 digit strings: 18, 36 and 1,800 differing strings; RegexpQuery and the classic QueryParser return [05] instead of [5]; passes with the fix.
Cross-model reviews, lenses (OpenAI GPT-5.6) refute: not real (high); root cause: not real (medium); intended: real (medium)Reading only. All agreed on the root cause; the root-cause lens graded its works-as-designed argument strong, yet wrote that "the case is not conclusive".
Devil's advocate real, low priority+ is the only non-digit the block lets through; a{+2} is rejected; traced the leak to JDK-5017980.
Adjudication confirmed, + half only, minorRe-ran the reproducer's test with assertions on and off.
Credibility and noise audits kept (downgraded to low impact)Undocumented spelling on an experimental API; repro cut to one test.
Current-main check and tie-break realMain 645d4fe14a20, 10.6.0 and 10.5.2 fail with assertions on and off; a new reading-only GPT-5.6 refute review said not real (medium), on the contract argument and procedurally, because it could not run code.
This doc still reproduces; fixes validatedGradle on a scratch worktree of 645d4fe14a20.

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 notes1,331 words

Finding record

LFV-813, snapshot of 2026-11-06

Registry title
RegExp numeric interval <n-m> accepts a leading '+' on a bound and counts it as a digit, so <+1-+9> matches 01..09 instead of 1..9 and <+100-999> loses its fixed width
Area
util / automaton
Priority
Low, rank 920: An undocumented '+' on a RegExp interval bound silently changes the matched language, but only with a contrived spelling.
Impact class
minor
Impact note
Hits anyone whose regular expression writes a numeric-interval bound with a leading '+', passed with the default flags to RegexpQuery or the classic QueryParser /.../ syntax [exec] (by reading also the flexible StandardQueryParser, Intervals.regexp and RegexCompletionQuery): the query silently matches a different set of strings (for <+1-+9> the two-character strings 01..09, not 1..9) instead of failing with 'interval syntax error'; documented unsigned ASCII bounds are exhaustively correct, no setting or scale is needed, and it is in every release from 4.0.0-ALPHA to 10.6.0 on Java 7 or later.
Summary
You run a RegexpQuery, or a classic QueryParser query /<+1-+9>/, on a field with the terms 5 and 05. The RegExp javadoc says interval bounds are non-negative decimal integers and that bounds with the same number of digits fix the length of the matching strings, so you expect either an 'interval syntax error' (as for '-1', ' 1' or an overflowing bound) or the meaning of <1-9>, which matches 5. Instead the parser accepts the '+' and counts it as a digit, so <+1-+9> matches only 01 to 09 and returns 05; <+1-9> and <+100-999> lose their fixed width and accept any number of leading zeros, and <+100-+999> means 0100 to 0999.
Introduced
b679816a7001ba986618f103a9a9c67ececc7ff8 (svn trunk r931278, 'LUCENE-2370: Reintegrate flex_1458 branch into trunk (revision 931101)'; the code is the dk.brics.automaton port of LUCENE-1606; SVN era, no GitHub PR; the flex-branch commits are not in the history clone) [read: git log -S 'Integer.parseInt(smin)' and git log -L in the history clone]. Latent until Java 7: Integer.parseInt accepts a leading '+' only since JDK 7 (JDK-5017980, fixVersion 7, resolved 2011-05-17); before that '+1' threw NumberFormatException, which this block turns into 'interval syntax error' [read: JDK bug record and the code's handler; no Java 6 run]. Not a Lucene regression: the block has had only two cosmetic edits since (887fe4c83d4, 2020-07-08, LUCENE-9386; fb8183332bc, 2023-08-17, #12462) [read] (2010-04-06 (Lucene commit); observable only on Java 7 or later runtimes (JDK-5017980, resolved 2011-05-17))
First affected version
4.0.0-ALPHA [exec on JDK 25, plain classpath, jar SHA-1 matches Maven Central]; 4.0.0-ALPHA to 4.7.2 supported Java 6, where the input was rejected [read, not executed: no Java 6 runtime], so 4.8.0 (Java 7 minimum) is the first release affected on every supported runtime; 3.0.1 and 3.6.2 have no automaton package [exec: API absent]
Affected versions
every release from 4.0.0-ALPHA to 10.6.0, and main a9779ef3c193 [exec on JDK 25: the minimal test fails on the 10.6.0 jars with -ea and -da (2026-10-31); the follow-up's main() history repro fails on 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0, 4.7.2 (also on JDK 11), 4.8.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 6 of 7 on main (-ea and -da), 9.12.3, 10.0.0 and 10.5.2; the finder's JUnit fails 4 of 4 on main (-ea and -da), 9.12.3 and 10.5.2; read: the same parse block in all 123 release tags of the history clone that contain the introducing commit]. Runtime condition: Java 7 or later. Not fixed upstream: upstream main 20ced71c1e6e (2026-10-27) has the same block, and the newest RegExp.java commit is ba69bfb1ea9a (author date 2026-07-31, unrelated) [read 2026-10-28]. The origin library dk.brics.automaton has the same code
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-27, the prior-report record: the scripted prior-report search (30 GitHub issue and PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 405 distinct hits, 0 high overlap), 23 manual GitHub searches (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, apache/lucenenet, the origin library cs-au-dk/dk.brics.automaton and GitHub-wide), 7 manual JIRA queries (LUCENE, SOLR, LUCENENET), the changed files of all 410 open apache/lucene PRs (only #14350 and #14389 touch RegExp.java, neither the interval branch) and of every PR created 2026-10-24 to 2026-10-27; 20 hits classified, none the same bug). Refreshed 2026-10-28 for this row: upstream main is still 20ced71c1e6e (author date 2026-10-27); the newest RegExp.java commit is ba69bfb1ea9a (author and committer date 2026-07-31, unrelated); none of the 14 PRs created since 2026-10-26 (#16907-#16925) touches RegExp, RegexpQuery or a query parser (file lists checked); the only item updated since 2026-10-27 that mentions RegExp is issue #16918 (a deprecation list naming RegExp.ASCII_CASE_INSENSITIVE, unrelated); the origin library has had no commit or issue since 2026-10-24. Not fixed upstream. Closest items, not reports: #12251 (open since 2023-04-28, drzhonghao: quotes this parseSimpleExp block but reports NumberFormatException handling in parseRepeatExp {n,m}) and its fix PR #12277 (merged 2023-11-02, parseRepeatExp only, a{99,11} now rejected); #10410 (LUCENE-9370, closed 2020: RegExp leniency on '\' turned into an error, a precedent); the origin library's 2026-10-24 RegExp.toString fix for numerical intervals (commit 43fbff6420, printing only; its bound parsing is unchanged and has the same '+' defect, unreported there).
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree, assertions on and off; the standalone runner; probes on the jars); the 10.6.0 and 10.5.2 release jars with the minimal test, assertions on and off; the study's runs on main a9779ef3c193 (the automaton package, RegexpQuery and the queryparser module are identical) and the history repro on 4.0.0-ALPHA, 4.0.0, 4.7.2, 4.8.0, 4.10.4, 5.0.0, 6.0.0, 7.0.0, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0; both reject fixes in a scratch worktree of 645d4fe14a20. Upstream main 2f424fd428da (2026-10-30) has the same parse lines.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:271-272 (at a9779ef3c193) (grammar row <n-m>, 'numerical interval', [OPTIONAL]), :341-343 (reserved characters must be escaped), :347-350 (class javadoc: 'Numerical intervals are specified by non-negative decimal integers and include both end points, and if n and m have the same number of digits, then the conforming strings must have that length (i.e. prefixed by 0's).'), :352 (@lucene.experimental), :728-729 (REGEXP_INTERVAL -> Automata.makeDecimalInterval(min, max, digits)), :1270-1279 ({n,m} reads ASCII digits only, peek("0123456789")), :1462-1491 (interval branch: rejects a '-' at position 0, at the end or a second '-'; Integer.parseInt(smin) and (smax) at :1478-1479; digits = smin.length() when both raw lengths match, :1481; NumberFormatException rethrown as 'interval syntax error' at :1490)
  • lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:49-56 (at a9779ef3c193) ('By default, all regular expression features are enabled'; RegexpQuery(Term) uses RegExp.ALL) and lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:511-513 (newRegexpQuery uses RegExp.ALL)
  • Registration check prior-art refresh 2026-10-28 (read-only GitHub API: apache/lucene commits for RegExp.java and main HEAD, PR search created:>=2026-10-26 with the changed files of all 14 PRs, issue and PR search updated:>=2026-10-27 RegExp, issue #16918, cs-au-dk/dk.brics.automaton commits and issues)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:271-273, (at 645d4fe14a20) 347-350, 408, 411, 513, 728-729, 1460-1465, 1474-1490
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:490-501 (at 645d4fe14a20)
  • OpenJDK 25.0.3 lib/src.zip java.base/java/lang/Integer.java (parseInt javadoc: plus sign, parseInt("+42", 10) returns 42)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:40, (at 645d4fe14a20) 50, 54-56
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:511-513 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/RegexpQueryNodeBuilder.java:45-50 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:237-238 (at 645d4fe14a20)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/RegexCompletionQuery.java:60-61 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:352 (at 645d4fe14a20) (@lucene.experimental)
Minimal repro
  • Lucene main 645d4fe14a20 gradle/validation/ecj-lint/ecj.javadocs.prefs:136; lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:803-805
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:347-350, (at 645d4fe14a20) 352, 510, 521, 533, 1272-1279, 1474, 1489-1490
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestRegExpParsing.java:465-513; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestRegexpQuery.java:98-99
History
  • a Lucene clone releases/lucene/10.6.0 RegExp.java:1501-1504 (same lines)

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

Repro files

About the diagrams

Drawn from the code at 645d4fe14a20 and the 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.