All findings · Lucene finding · LFV-020 · kleene-star-of-empty-language

The Kleene star of the empty language returns the empty language instead of {""}

X* must always match the empty string (zero repetitions), even when X matches nothing. Lucene gets this wrong when X is an empty automaton with 0 states: RegexpQuery("(a&b)*") misses the empty term and a(a&b)* misses the term a (0 hits instead of 1, no error), in every release since 4.10.3.

Summary

Priority
Low, rank 918The Kleene star of the empty language returns the empty language instead of the empty string, so patterns like (a&b)* match nothing, a near-contrived input with a trivial fix.
Impact
silent wrong results
Since
4.10.3 (LUCENE-6054 fix, 2014-11-07)introduced 2014-11-07 · over 11 years
Affected
4.10.3 to 10.5.2 and main; correct on 4.9.1 (run)
Status
documented
Found by
Jazzer differential fuzzing of RegExp 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

X* means "zero or more repetitions of X". Zero repetitions is the empty string, so X* always accepts it, even when X accepts nothing.

Summary card titled 'Lucene: the Kleene star of an empty language accepts nothing, not the empty string'. Panel 1, You write: new RegexpQuery(new Term("f", "a(a&b)*")) over two documents with the terms "" and "a"; it should be (a&b) = { }, (a&b)* = { "" } and a(a&b)* = { "a" }. Panel 2, how RegExp and RegexpQuery build it: Operations.intersection gives (a&b) 0 states; Operations.repeat returns its 0-state input as is (Operations.java:202-204, return a; should return an automaton for { "" }); Operations.concatenate is empty because an operand has 0 states; CompiledAutomaton is of type NONE and matches no term. Panel 3, the search: a(a&b)* expected 1, actual 0; (a&b)* expected 1, actual 0; the controls a(a&b)? and (a&b)? give 1 as expected. No exception; wrong in the 20 releases run from 4.10.3 to 10.5.2 and in main; correct in 4.9.1 and, with assertions off, 4.10.0-4.10.2. Footer: introduced in 2014 by the fix for LUCENE-6054; fix candidate return Automata.makeEmptyString(), with 2 tests that pin the old result to update.
How RegExp and RegexpQuery build a(a&b)*: the intersection comes out with 0 states, repeat returns it as is, and the concatenation and the query match nothing. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index two documents with a StringField "f": one with the empty term "", one with a. Search with RegexpQuery (default flags) for (a&b)* and for a(a&b)*.
  2. No string is both a and b, so (a&b) matches nothing and (a&b)* is zero repetitions: the empty string. You expect 1 hit for each query ("" and a).
  3. You get 0 hits for both, and no error. #* and (#){0,} also reject "", while (a&b)? and (a&b){0,5} accept it.
  4. Why: Lucene usually stores "matches nothing" as an automaton with 0 states, and Operations.repeat returns such an input unchanged instead of adding the empty string (Operations.java:202-205). A concatenation with an empty part is empty, so a(a&b)* matches nothing too.
  5. The same empty language built with 1 state gives the right answer. The shortcut came from a 2014 crash fix (LUCENE-6054); 4.9.1 was correct.

Who is affected

Anyone whose regex or automaton stars (or {0,}s) a sub-expression that matches nothing and has 0 states, which is how #, an impossible & such as (a&b), an empty character class and Automata.makeEmpty() are built. Default flags; both query parsers too (/a(a&b)*/ gives 0 hits). Rare, but then the whole enclosing pattern silently matches nothing.

Mechanism, terms and numbers710 words

X* means zero or more repetitions of X, and zero repetitions is the empty string, so X* accepts "" even when X accepts nothing. Lucene usually represents "accepts nothing" as an automaton with 0 states (Automata.makeEmpty(), #, an impossible intersection such as (a&b), an empty character class), and Operations.repeat(Automaton) returns such an input unchanged. So #*, (a&b)* and (#){0,} accept nothing, and because a concatenation with an empty part is empty, a(a&b)* matches nothing at all: RegexpQuery and both query parsers' /.../ syntax silently return 0 hits where 1 is expected. The same empty language built with 1 state gives the correct {""}. The 0-state shortcut came from the 2014 LUCENE-6054 crash fix; every release from 4.10.3 to 10.5.2 and main is affected, and 4.9.1 was correct.

Terms

  • Language: the set of strings an automaton or regex accepts. The empty language { } accepts nothing; the empty-string language {""} accepts exactly one string, the empty one. They are different.
  • Kleene star X*: zero or more repetitions of X. Zero repetitions always gives "", so for every X, X* contains "". In particular { }* = {""}.
  • How X can be empty in Lucene's regex syntax (all enabled by default, RegExp.ALL): # ("the empty language" in the RegExp grammar), an impossible intersection such as (a&b) or ([a-c]&[x-z]), an empty character class such as [^\s\S]. In code: Automata.makeEmpty(), an intersection with no common string, Automata.makeStringUnion of an empty list.

The bug

  1. Lucene usually represents the empty language as an automaton with 0 states: Automata.makeEmpty() (Automata.java:55-60, also used for #), Operations.intersection (it returns a 0-state operand as is and ends with removeDeadStates, which drops every state when nothing can reach an accept state; Operations.java:396-401, 436, 1007-1043), makeCharClass with no ranges, makeStringUnion of an empty list.
  2. Operations.repeat(Automaton) starts with a shortcut (Operations.java:202-205 at ea57360912ed):
if (a.getNumStates() == 0) {
  // Repeating the empty automata will still only accept the empty automata.
  return a;
}

The general path below it starts with a new accepting initial state (:212-215), so it accepts "". A second shortcut (:207-210) returns an input whose only accept state is its initial state; such an input already accepts "", so that shortcut is correct too. Only the 0-state shortcut returns an automaton that rejects "".

  1. {0,} takes the same path: repeat(a, 0) calls repeat(a) (:273-276), and RegExp maps * and {n,} to these two methods (RegExp.java:665-671). ? and {0,k} are correct, because optional adds a state when there is none (:177-180) and repeat(a, 0, max) starts from makeEmptyString() (:297-298).
  2. concatenate returns the empty automaton as soon as one part has 0 states (:79-81). So the error spreads to the whole pattern: a(a&b)* = a followed by "nothing" = nothing.
  3. RegexpQuery hands the automaton to CompiledAutomaton (AutomatonQuery.java:99), which replaces a 0-state automaton with a 1-state one that accepts nothing (CompiledAutomaton.java:154-157) and classifies it as AUTOMATON_TYPE.NONE (:166-168). The wrong language is kept, no term matches, and no error is raised.

The numbers

expressionshould acceptmain and 10.5.2states
Operations.repeat(Automata.makeEmpty())""rejects0
repeat of a 1-state, non-accepting automaton (same empty language)""accepts1
RegExp #*, (a&b)*, (#){0,}, [^\s\S]*""reject0
RegExp (a&b)?, (a&b){0,5} (controls)""accept1
RegExp a(a&b)*"a"rejects0
RegExp x([a-c]&[x-z])*y"xy"rejects0
RegExp a(a&b)? (control)"a"accepts2

RegexpQuery over the terms "" and a: (a&b)* and a(a&b)* count 0 hits each where 1 is expected; the controls a* = 2, (a&b)? = 1 and a(a&b)? = 1 are right. #+, (a&b)+ and (#){1,} correctly accept nothing.

Same language, different answer. An independent test compared repeat() with the textbook definition of L* on all 31 words over {a,b} of length 0 to 4, for 2,000 random automata with 0 to 4 states: all 384 of 384 0-state inputs were wrong, none of the 1,616 inputs with at least one state were, and 775 of those also had the empty language. A second seed gave 418 of 418 against 0 of 1,582.

Exposure and likelihood304 words
  • Who is hit. Any code that stars an empty operand with 0 states. That is how #, impossible intersections, empty character classes and Automata.makeEmpty() represent the empty language; an empty operand with 1 or more states is handled correctly:
    • new RegExp(String) (default RegExp.ALL, which enables # and &; RegExp.java:512-513) and RegexpQuery(Term) (RegexpQuery.java:54-55);
    • the classic and the flexible query parser, which both build RegexpQuery with RegExp.ALL (QueryParserBase.java:511-513, RegexpQueryNodeBuilder.java:45-50). Over the terms a and ab, /a(a&b)*/ counts 0 hits in both, where 1 is expected; /a(b)*/ = 2 and /a(a&b)?/ = 1 are right;
    • code that composes automata with the public Operations and Automata API, where an operand can come out empty on its own: an intersection with no common string, a string union of an empty word list, an empty character class, or a difference whose left language is contained in the right. Operations.repeat of each of these rejects "".
  • Default, not opt-in. No flag or setting is involved.
  • Silent, and wider than the empty string. No exception, with assertions on and off. Because a concatenation with an empty part is empty, a whole pattern such as prefix(<empty>)*suffix matches no term at all. As a standalone positive query it only under-matches (no crash, no index damage); under a negation (a MUST_NOT clause, Operations.complement, a deny filter) the miss turns into an over-match.
  • How likely. Rare. #* or (a&b)* is unlikely in a hand-written query; generated patterns and composed automata are the more plausible path. No report from any downstream user is known.
  • Scale. None needed: #* is three characters.
  • Long-standing. Every release from 4.10.3 (2014) to 10.5.2, and main.
  • Workaround. Write (X)? or (X){0,k} instead of (X)* when X may be empty; in code, check Operations.isEmpty(x) before calling repeat and use Automata.makeEmptyString().

Minimal repro

The test shows that Operations.repeat(Automata.makeEmpty()), the Kleene star of the empty language, returns the empty language, so it rejects "".

TestKleeneStarOfEmptyLanguage.java (JUnit test)28 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 TestKleeneStarOfEmptyLanguage extends LuceneTestCase {

  public void testStarOfEmptyLanguageAcceptsEmptyString() {
    Automaton star = Operations.repeat(Automata.makeEmpty());
    // expected: the star (zero or more repetitions) accepts "", i.e. true; actual: false
    assertEquals("repeat(makeEmpty()) accepts \"\"", true, Operations.run(star, ""));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-020/, 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.TestKleeneStarOfEmptyLanguage
Expected vs actual
Expected: the star (zero or more repetitions) accepts "" (true). Actual: false (expected:<true> but was:<false>).

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

More files, environment, full output36 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: repeat(makeEmpty()) accepts "" expected:<true> but was:<false>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.util.automaton.TestKleeneStarOfEmptyLanguage.testStarOfEmptyLanguageAcceptsEmptyString(TestKleeneStarOfEmptyLanguage.java:26)

Why it looks unintended

  • The javadoc promises "the Kleene star (zero or more concatenated repetitions)"; the RegExp grammar says * is "zero or more occurrences".
  • The shortcut came from a 2014 crash fix that never discussed the result; 4.9.1 returned the correct {""}.
  • The answer depends on the state count, not the language: 384 of 384 0-state inputs are wrong, 0 of 1,616 others.
The full argument498 words

The contract, in its own words (at ea57360912ed):

  • Operations.repeat(Automaton) (Operations.java:196-197): "Returns an automaton that accepts the Kleene star (zero or more concatenated repetitions) of the language of the given automaton."
  • repeat(Automaton, int) (:268-269): "accepts min or more concatenated repetitions"; for min = 0 that includes zero repetitions.
  • RegExp grammar: * "(zero or more occurrences)" (RegExp.java:121), {n,} "(n or more occurrences)" (:142), ? "(zero or one occurrence)" (:114), # "(the empty language)" (:230).
  • The package doc promises "all standard (and a number of non-standard) regular expression operations" and points to dk.brics (package-info.java:33-34, 40-42). In #15182 a committer (rmuir) wrote that the syntax "should continue to match https://www.brics.dk/automaton/". Lucene 4.9.1's repeat, which came from dk.brics, returns {""} here.
  • optional and repeat(a, 0, max) in the same class already return {""} for the empty language.
  • No javadoc, CHANGES.txt or MIGRATE.md text says the star of the empty language is empty. CHANGES.txt lists the change only as "LUCENE-6054: Allow repeating the empty automaton", under bug fixes (lines 11525 and 11719).

History.

  • LUCENE-6054 (GitHub #7116, reported by nik9000) is a crash report: "RegExp.toAutomaton fails on #*", body "This throws an assertion error: new RegExp("#*").toAutomaton(1000);". The fix (nik9000's patch, committed by mikemccand as 54156c042d4 on 2014-11-07, about 9 minutes after the issue was filed) added the return a branch. The comments are thanks, commit notices and a bulk close; nobody discussed what #* should accept. Its test (TestRegExp.testRepeatWithEmptyLanguage, TestRegExp.java:107-117) only checks that toString() is non-empty ("paranoia").
  • Before the fix, in 4.10.0-4.10.2, a 0-state input went down the general path: with assertions on it threw AssertionError: state=0 nextState=0 (the reported crash); with assertions off it returned the correct {""}. So the crash fix silently changed production results. Reverting only that hunk inside 4.10.3 restores the 4.10.2 result on all five bug checks (they throw again with assertions on; with them off they return {""} and the whole output is identical to 4.10.2's). With assertions on, one control differs: 4.10.2 also throws on repeat(makeEmpty(), 0, 3) and the reverted 4.10.3 does not, because LUCENE-6046 rewrote the bounded repeat in 4.10.3.
  • The tests that pin today's result came later, from optimisation work. TestOperations.testRepeatEmptyLanguage (TestOperations.java:304-309) asserts assertSame(input, Operations.repeat(input)) for makeEmpty(): jpountz added the assertion in #13714 (2024-09-05, "Make Operations#repeat create simpler automata"), and rmuir moved it into its own test in #14218 (2025-02-13, dead-state clean-up), adding AutomatonTestUtil.assertMinimalDFA(actual), which treats the 0-state result as the expected minimal DFA. testDuelRepeat's oracle naiveRepeat, labelled "the original implementation of Operations#repeat" (:487-491), copies the same return a, so it cannot catch this. The study read both PRs and their review comments: nobody discusses what the star of the empty language should be.

Tests. With the fix, the only test failures in all of :lucene:core:test and six modules that use RegExp are those two pinning tests; with them updated, everything passes (see the fix section).

The devil's-advocate check332 words

An independent reviewer from another model family (OpenAI GPT-5.6), asked to refute the claim, made the strongest case for "intended" (verdict not real, high confidence): the 0-state branch has an explanatory comment; it was added on purpose, with a CHANGES entry; a dedicated assertSame test (with assertMinimalDFA on the result) and a copied oracle pin it; two later reviewed PRs (2024, 2025) kept and strengthened it; and these APIs are @lucene.experimental. It called the behaviour "conclusively intentional in the current implementation". The reviewer itself reproduced the bug, agreed on the root cause and that the inputs are valid, and conceded that the history "does not articulate why empty-language star should depart from the documented operation" and that #13714 and #14218 "were not semantic discussions".

Why it fails (the second adjudication, verdict real):

  1. The artifacts show that the branch and its result were chosen and kept on purpose: the comment states the result, and assertSame, assertMinimalDFA and the copied oracle preserve it. None of them explains the result or reconciles it with the documented Kleene star; there is no "by design" or "won't fix" anywhere.
  2. The branch comes from a crash fix committed minutes after the report, whose own test never checks what is accepted; the pinning tests come from an optimisation and a clean-up, and the oracle copies "the original implementation".
  3. The code comment is not API documentation, gives no reason, and contradicts the javadoc six lines above it; all public docs say the opposite.
  4. The result depends on the number of states, not on the language (384 of 384 against 0 of 1,616). A rule about languages cannot split results that way; if the split is deliberate, it is a rule about representations, and the javadoc defines repeat by the language alone.
  5. @lucene.experimental allows API changes, not results that contradict the API's own javadoc.
  6. Before 2014, production code returned the correct {""}; the change was announced only as "Allow repeating the empty automaton".

Verdict: not intended, a defect.

Suggested fix

Recommended Return {""} for a 0-state input

In Operations.repeat(Automaton), return Automata.makeEmptyString() instead of the 0-state input, and update the two tests that pin the old result (testRepeatEmptyLanguage and the naiveRepeat oracle). With it the repro passes 7 of 7, the whole :lucene:core suite runs with 0 failures (8,873 tests, 322 of them skipped), and six modules that use RegExp pass.

public static Automaton repeat(Automaton a) {
  if (a.getNumStates() == 0) {
    // Zero repetitions are always allowed: the Kleene star of the empty language is {""}.
    return Automata.makeEmptyString();
  }
All options and trade-offs3 options ยท 507 words

One option was validated with gradle in a scratch worktree at ea57360912ed; the other two are alternatives that were not run. The scratch worktree was removed after the runs.

Return {""} for a 0-state input (validated by the checks) (recommended)

One line in Operations.repeat(Automaton) plus the two pinning tests in TestOperations. testRepeatEmptyLanguage then asserts that the result is a minimal DFA with the same language as Automata.makeEmptyString(), and naiveRepeat gets the same one-line change. A regression test for TestRegExp (#*, (a&b)*, (#){0,} accept only ""; a(a&b)* accepts a) is in the repro section.

public static Automaton repeat(Automaton a) {
  if (a.getNumStates() == 0) {
    // Zero repetitions are always allowed: the Kleene star of the empty language is {""}.
    return Automata.makeEmptyString();
  }

Trade-offs

  • Matches the javadoc, the RegExp grammar, optional, repeat(a, 0, max), the 1-state empty automaton and 4.9.1. makeEmptyString() is a 1-state minimal DFA. Cost: one new 1-state automaton per call on a 0-state input instead of returning the input, a small constant; not benchmarked.
  • #+, (a&b)+ and (#){1,} stay empty, as they should.
  • Query compilation changes with it: (a&b)* and a(a&b)* compile to AUTOMATON_TYPE.SINGLE (the term "" and the term a) instead of NONE (executed).
  • Visible change: patterns with a starred empty sub-expression match more afterwards (the empty term, or the whole enclosing concatenation). Worth a CHANGES line.
  • repeat() no longer returns the same instance for an empty input; in-tree only the assertSame test relied on that.

What the checks validated

  • The repro: stock 7 tests, 6 failed; with the fix 7 of 7 pass. The TestRegExp regression test fails on stock (assertions off) and passes with the fix.
  • 34 core classes (util.automaton.*, TestRegexp*, TestAutomatonQuery*, TestWildcard*, TestPrefix*, TestTermsEnum*, the DocValues and FieldCache rewrite methods, TestQueryBuilder, TestMatchesIterator, TestDisjunctionMaxQuery), seed DEADBEEF: stock 388 tests, 6 failures (all in the repro); code fix only: exactly 2 failures, testRepeatEmptyLanguage and testDuelRepeat; with the 2-test update: 0 failures.
  • Assertions off, seed 1234ABCD (util.automaton.*, TestRegexp*, TestAutomatonQuery*): 267 tests, 0 failures.
  • The whole :lucene:core:test (fix, both test changes and the repro): 748 classes, 8,873 tests, 0 failures, 322 skipped.
  • queryparser 533, highlighter 722, monitor 181, queries 512, memory 51 and suggest 236 tests: 0 failures. Not run: analysis.tests and the other modules.
  • :lucene:core:checkGoogleJavaFormat passes; the TestRegExp method was formatted with tidy.

Special-case the star in RegExp.toAutomaton only (not run)

Map * and {0,} of an empty operand to makeEmptyString() in RegExp.

Trade-offs

Fixes regex users but leaves the public Operations.repeat wrong, and dependent on the state count, for code that composes automata. Not recommended.

Keep the behaviour and document it (not recommended)

Amend the javadoc and the RegExp grammar.

Trade-offs

The text would have to say that the result depends on the number of states (0 states: empty; 1 dead state: {""}) and make an exception for * and {n,}. It would contradict optional, repeat(a, 0, max), dk.brics and 4.9.1.

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 them, plus consistency checks across Operations. A separate AI verifier wrote its own tests and traced the cause to the LUCENE-6054 fix.

The method in full158 words

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

  • An AI tool ran the RegexDiffFuzzer target: one random regex syntax tree is rendered both as a Lucene RegExp and as a java.util.regex pattern and the two are compared, with self-consistency checks across Operations (run, complement, intersection, union, reverse, minimize). About 145 k executions in 5 runs produced this bug and three other automaton and regex findings.
  • A separate AI verifier wrote its own 10-test JUnit (9 fail; the in-test controls pass), traced the history to LUCENE-6054, ran 4.9.1 (correct) and 4.10.4, 5.0.0, 10.1.0, 10.5.2 and main (wrong), and tested the fix against 210 Lucene tests.
  • On 2026-10-26 the hunt re-ran it on ea57360912ed (-ea, -da, 10.5.2, 9.12.3), obtained a cross-model refute review and a second adjudication, built the release matrix with -ea and -da, and searched for prior reports. For this page the fix was validated with gradle.

Prior reports, history and checks

Prior reports

No report found in GitHub issues and PRs, the JIRA archive, the dev@, java-user@ and issues@ lists, downstream trackers or the web (71 tool queries plus manual searches, 2026-10-26). Not fixed upstream. The closest item is LUCENE-6054 (#7116), the crash whose fix caused it.

Searches5 searches

No report of this bug was found, and upstream has not fixed it. Nothing says that #*, (a&b)* or (#){0,} should accept "", or that a(a&b)* or RegexpQuery("(a&b)*") matches nothing. Closest items, none of them this bug: #7116/LUCENE-6054 (the crash whose fix caused it), #13714 and #14218 (pinned the result in tests), #15182 (open: empty alternatives in parsing, and the dk.brics statement), #14224 (empty [] class parsing), #16816 (open: rewrites a NONE-type AutomatonQuery to MatchNoDocsQuery and leaves repeat alone), #13706/#13707 (isTotal on repeat), #8970, #3791 (LUCENE-2717) and #2680 (LUCENE-1606).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), via a scripted prior-report search2026-10-2630 queries: Operations, RegExp, Automata, RegexpQuery, repeat, makeEmpty, makeEmptyString; "Kleene star", "empty language", "empty automaton", "repeating the empty", testRepeatEmptyLanguage, naiveRepeat; "star of the empty language", "accepts the empty string", "matches nothing"no report; LUCENE-6054/#7116 came back at rank 5
Commits, downstream, mailing lists, JIRA, web (same tool)2026-10-264 commit queries, 3 downstream, 24 on dev@, java-user@, issues@ and commits@, 4 in the JIRA archive, 6 web; 71 queries in total, 0 errors, 2,813 distinct hits, comments of the top 20 readnothing about this bug
Manual searches2026-10-26GitHub ("empty language", Kleene, repeat empty in:title, "empty automaton" in:title, intersection) on apache/lucene, two downstream search-engine trackers and apache/lucenenet; 11 JQL queries in the JIRA archive; lists.apache.org, all dates; 10 web searchesunrelated items and textbook material only
Open PRs and branches2026-10-26changed files of the 412 open PRs updated since 2025-06-01; Operations.java history on main, branch_10x, branch_10_6 and branch_10_5; CHANGES.txt and MIGRATE.md at ea57360912edthe 0-state branch is on all four branches; only #14389, #14350 and #14360 touch the automaton classes, none of them repeat
Earlier searches2026-10-22 and 2026-10-26the study (about 22 GitHub queries, dev@, web) and the candidate stepno report

History

Introduced by the LUCENE-6054 crash fix (2014-11-07, 54156c042d4 and its backports): wrong in every release run from 4.10.3 to 10.5.2 and in main, with assertions on and off. 4.9.1 was correct; 4.10.0-4.10.2 threw with assertions on and were correct with them off.

Versions tested6 rows

LUCENE-6054 ("allow repeating the empty automaton") added the same 4-line hunk on three branches on 2014-11-07: fed5251becd (lucene_solr_4_10, first released in 4.10.3), c349c30655d (branch_5x, 5.0.0) and 54156c042d4 (trunk, 6.0.0). Its precondition was LUCENE-5752 (46132142cc2 on branch_4x, first released in 4.10.0; trunk ec2eaaafad8), which made makeEmpty() a 0-state automaton. The branch is present at all 100 release tags from 4.10.3 to 10.5.2; git blame on main still attributes it to 54156c042d4.

Every release row ran a main() probe (5 checks; 4 controls, 3 on 4.9.1, whose makeEmpty() already has 1 state) on the release's Maven Central jars, SHA-1 checked, with -ea and -da. The main row ran the same probe on the locally built jar; the fix row is the JUnit and gradle runs.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affectedwrong with -ea and -da; the JUnit repro fails 6 of 7
4.10.3 to 10.5.2 (20 releases run: 4.10.3, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2) affectedwrong with -ea and -da: all 5 checks false, all 4 controls pass
4.10.0, 4.10.1, 4.10.2 crash with -ea-ea: AssertionError state=0 nextState=0 (the LUCENE-6054 crash); -da: correct
4.9.1 not affectedcorrect with -ea and -da
4.10.3 with only the LUCENE-6054 hunk reverted revert checkthe 5 bug checks match 4.10.2 again (throw with -ea, correct with -da); the -da output is identical
main ea57360912ed with the fix fixthe repro passes 7 of 7; all core tests pass with the 2-test update

Independent checks

The study's verifier, the public-list rerun, the hunt's re-verification and a second adjudication confirmed it. A cross-model refute review reproduced it but argued it is intended; the second adjudication weighed that case and ruled it a real bug.

Checks and verdicts416 words

Each check worked from the code or its own runs. The only disagreement was on intent, and it is shown in full in "Why it is a bug".

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (fuzzing/PBT) new bugIts own 10 tests: 9 fail, the in-test controls pass. Traced it to LUCENE-6054, ran 4.9.1 (correct) and 4.10.4, 5.0.0, 10.1.0, 10.5.2 and main (wrong), and tested the fix against 210 Lucene tests.
Public-list rerun reproduces on b83101cdd210 tests, 9 failures, the same tests as the study.
Hunt verification realThe 7-test JUnit fails 6 of 7 on main with -ea and -da, on 10.5.2 with both, and on 9.12.3; the one-line fix makes it pass.
Cross-model refute review not real (intent only)An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own tests: they fail on main with -ea and -da and with three seeds, and pass with the fix. It agreed on the root cause and that the inputs are valid, but judged the result intended because of the code comment and the pinning tests. The repro driver re-ran its tests with the same result.
Second adjudication real, high confidenceWrote an independent reference-semantics test (384 of 384 0-state inputs wrong, 0 of 1,616 others), ran both query parsers (0 hits instead of 1), ran 4.9.1, 4.10.0, 4.10.2 and 4.10.3 with -ea and -da, read LUCENE-6054, and ran 24 Lucene test classes with the fix (277 of 277 with the 2-test update).
History follow-up introduced by LUCENE-6054, first released in 4.10.324 releases plus main with -ea and -da, and a single-hunk revert inside 4.10.3.
Prior-report follow-up new71 tool queries plus manual searches; nothing found.
This page's own runs reproduced; fix validatedGradle in a scratch worktree: stock 6 of 7 fail (also with assertions off); with the fix and the 2-test update the whole core suite (8,873 tests, 0 failures, 322 skipped) and six RegExp-using modules pass. The plain program gives the same table on main, with -da and on 10.5.2.
Fact checks of this page bug stands; wording fixedAn independent fact check (re-ran every repro), a cross-model fact check (OpenAI GPT-5.6) and a diagram check. None changed a conclusion; their wording findings (the revert check, the LUCENE-5752 commit, test-control and skipped-test counts, the trigger condition, the LFV-029 note, the cost of the fix) are fixed here, as listed in the hunt's record.

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 notes470 words

Finding record

LFV-020, snapshot of 2026-11-06

Registry title
The Kleene star of the empty language returns the empty language instead of {""}
Area
util / automaton
Priority
Low, rank 918: The Kleene star of the empty language returns the empty language instead of the empty string, so patterns like (a&b)* match nothing, a near-contrived input with a trivial fix.
Impact class
silent wrong results
Impact note
Default configuration; only regexes or automata whose repeated operand is the empty language, such as (a&b)*; released since 4.10.3.
Summary
You build a RegExp or call Operations.repeat on an operand whose language is empty, such as (a&b)*. You expect the result to accept the empty string, since the star of any language contains it. Instead the result accepts nothing.
Introduced
LUCENE-6054 fix 54156c042d4 (#7116) added the return a that makes the star of the empty language empty; LUCENE-5752 (ec2eaaafad8) had made makeEmpty() 0-state [read] (2014-11-07 (54156c042d4))
First affected version
4.10.3
Affected versions
4.10.3+ [read: LUCENE-6054 fixVersions]; 4.10.4, 5.0.0, 10.1.0, 10.5.2 and main [exec]; 4.9.1 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 disputed it (the study's other checks rated it real); 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-26): the JUnit repro through the repro runner with -ea and -da and through gradle in a scratch worktree, stock and with the fix (the worktree was removed after the runs); a plain-Java probe on main (-ea, -da), 10.5.2 and the fix; the Maven Central jars of 24 releases from 4.9.1 to 10.5.2 with -ea and -da (SHA-1 checked). The study reproduced it on 7cecf79523 and the public-list rerun on b83101cdd2; upstream main HEAD was still ea57360912ed on 2026-10-26.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java at ea57360912ed (lines 79-81, 177-180, 196-215, 268-276, 297-298, 396-401, 436, 1007-1043)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java at ea57360912ed (lines 55-60, 140-142, 159-166, 641-643); RegExp.java (lines 114, 121, 142, 230, 665-671, 698-699)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:512-513; lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:54-55 (at ea57360912ed)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:511-513; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/RegexpQueryNodeBuilder.java:45-50
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:377-383 at ea57360912ed (the deprecated minus: makeEmpty() or intersection(a1, complement(a2)))
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:196-205, 268-269 at ea57360912ed; RegExp.java:114, 121, 142, 230; package-info.java:33-34, 40-42
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java:304-309, 477-491; TestRegExp.java:107-117 (at ea57360912ed)
  • lucene/CHANGES.txt:11525, 11719 (at ea57360912ed)

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

Repro files

About the diagrams

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.