All findings · Lucene finding · LFV-713 · hunspell-compound-pattern-replacement-empty-tail-assert

Hunspell spell() and suggest() throw AssertionError when a CHECKCOMPOUNDPATTERN replacement with empty beginchars ends the word

With assertions on, Hunspell spell("foz") throws AssertionError instead of returning false for a dictionary with CHECKCOMPOUNDPATTERN o /X z: turning the final z back into o leaves an empty second part, which is then looked up. With assertions off the answer is right; every release since 8.9.0 has it.

Summary

Priority
Low, rank 1179This released AssertionError fires only under -ea and only for a CHECKCOMPOUNDPATTERN replacement with empty beginchars that only the stock Dutch dictionaries carry, by accident.
Impact
minor
Since
8.9.0 (LUCENE-9717 and LUCENE-9825, 2021)introduced 2021-02-03 · about 5 years
Affected
8.9.0-10.6.0 and main, with assertions on only
Status
documented
Found by
non-formal: differential testing against C Hunspell

AI-assisted page: this finding was first signalled by code reading or testing 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

One affix rule, one word and a 3-letter input are enough: a replacement that ends the word leaves an empty second part, and Lucene looks that empty part up.

Background: compounds and CHECKCOMPOUNDPATTERN

  1. A Hunspell dictionary can allow compounds: words marked with the COMPOUNDFLAG (here C) may be joined, and each part needs at least COMPOUNDMIN letters (default 3). To check a word, Lucene tries every break position and looks up both parts.
  2. CHECKCOMPOUNDPATTERN endchars beginchars forbids compounds whose first part ends with endchars and whose second part begins with beginchars. An optional third field, the replacement, allows a simplified spelling instead: with o b z, foo + bar may be written fozar.
  3. To check a simplified form, Lucene writes the replacement back as endchars + beginchars (fozar becomes foobar), puts the break right after endchars and checks both parts as usual.
Diagram of the bug. The dictionary has one word, foo, and the rule CHECKCOMPOUNDPATTERN o /X z: an o at the end of the first part followed by no letters may be written z. spell("foz") rewrites the final z as o plus nothing, giving foo, and puts the compound break after letter 3. foo is found as the first part, and the second part has 0 letters. Lucene looks up that empty part, and the lookup's check assert length > 0 throws AssertionError with assertions on; with assertions off the lookup finds nothing and spell returns false, as C Hunspell does. The fix skips a replacement that leaves no second part.
The word foz: the final z is turned back into o, giving foo; the break falls after letter 3 of 3, so the second part is empty and its lookup trips assert length > 0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Give the rule empty beginchars: CHECKCOMPOUNDPATTERN o /X z (/X means no letters, flag X, as hunspell(5) allows), so an o that ends the first part, followed by nothing, may be written z. Add COMPOUNDFLAG C, COMPOUNDMIN 1 and one word, foo/C, then call spell("foz").
  2. You expect false: foz would be foo plus an empty second word. C Hunspell 1.7.2 answers false, and so does Lucene with assertions off.
  3. With assertions on, spell throws AssertionError. Lucene turns the final z back into o, gets foo (3 letters), breaks after letter 3 and looks up the 0 letters left. The word lookup begins with assert length > 0.
  4. suggest() hits it too. Every release with the spell-checker, 8.9.0 to 10.6.0, and main are affected, but only runs with assertions on. No surveyed public dictionary uses such a replacement on purpose, but the stock Dutch dictionary has one by accident (a trailing # comment read as the replacement #): spell("huis#") throws too.

Who is affected

Applications that call the opt-in Hunspell spell-checker (spell, suggest) with assertions on (typically test runs) and a dictionary with a CHECKCOMPOUNDPATTERN replacement whose beginchars are empty. No surveyed public dictionary uses one on purpose, but the stock Dutch one has one by accident, so spell("huis#") throws. Analysis never runs this code. With assertions off, the repro gets C Hunspell's answer.

Mechanism, terms and numbers810 words

CHECKCOMPOUNDPATTERN endchars[/flag] beginchars[/flag] [replacement] (hunspell(5)) can carry a replacement, a simplified compound form in which the replacement letters stand for the boundary endchars|beginchars. When Lucene's Hunspell checks a compound it tries every break position, and at each one checkCompoundPatternReplacements (Hunspell.java:249-267) rewrites a matching replacement into endchars + beginchars (CheckCompoundPattern.java:94-103) and puts the break right after endchars (Hunspell.java:255). It never checks that anything is left after the break. With empty beginchars (written /X) and a replacement at the end of the word nothing is, so checkCompoundsAfter looks up a second part of length 0 (:272-275) and breaks assert length > 0 in WordStorage.lookupWord (WordStorage.java:106-107). With dictionary foo/C, COMPOUNDFLAG C, COMPOUNDMIN 1 and CHECKCOMPOUNDPATTERN o /X z, spell("foz") throws AssertionError with assertions on; with them off the empty lookup finds nothing and the answer is the correct false, which C Hunspell 1.7.2 also gives. suggest() goes through the same code. The stock LibreOffice Dutch dictionary reaches it too: a trailing # comment on a pattern with empty beginchars is parsed as the replacement #, so spell("huis#") throws with assertions on (with them off the answers match C Hunspell 1.7.2). The missing check came with CHECKCOMPOUNDPATTERN support (LUCENE-9717, 2021) and the assertion with LUCENE-9825 (2021); all 42 releases from 8.9.0 to 10.6.0 and main fail with assertions on. A one-line guard fixes it.

Terms

  • endchars / beginchars: the letters a CHECKCOMPOUNDPATTERN rule expects at the end of the first part and the start of the second. Each may carry a flag after /; /X alone means no letters, flag X. A flagless empty field cannot be written: the parser splits the line on whitespace (CheckCompoundPattern.java:32), so o z would make z the beginchars.
  • Replacement: the optional fourth field (CheckCompoundPattern.java:45), the simplified spelling of the boundary. hunspell(5): "The optional replacement parameter allows simplified compound form."

The bug (file:line at 645d4fe14a20)

  1. checkCompounds(CharsRef, ...) (Hunspell.java:213-247) tries breakPos from COMPOUNDMIN up to word.length - COMPOUNDMIN (:220-221), so on the regular path both parts have at least COMPOUNDMIN letters. At every position it also calls checkCompoundPatternReplacements (:241).
  2. checkCompoundPatternReplacements (:249-267) calls expandReplacement(word, pos) (:252), which, if the replacement matches at pos, returns the word with the replacement rewritten as endChars + beginChars (CheckCompoundPattern.java:94-103). The break is set to pos + pattern.endLength() (Hunspell.java:255), the first part is looked up (:256-257) and, if found, checkCompoundsAfter checks the rest (:259-260).
  3. At the first boundary (the call on the whole word, offset 0, as in the repro) the second part has |beginChars| letters plus whatever followed the replacement, so it is empty exactly when beginchars are empty and the replacement ends the word. Nothing checks for that. (At later boundaries of a multi-part compound, stock expandReplacement returns the whole word with offset 0, the sibling bug LFV-377, so the earlier parts count as the remainder and the empty lookup does not happen there.)
  4. checkCompoundsAfter (:269-286) sets remainingLength = word.length - breakPos (:272), here 0, and calls findStem with it (:274-275): Stemmer.doStem (Stemmer.java:227-229), Dictionary.lookupWord (Dictionary.java:283-285), then WordStorage.lookupWord, whose first line is assert length > 0; (WordStorage.java:106-107).
  5. With assertions off the zero-length lookup finds no entry, the compound attempt fails and spell returns false. The read word[offset + length - 1] (WordStorage.java:118) stays in bounds because the break offset is at least 1 (COMPOUNDMIN is at least 1, Dictionary.java:438).

The numbers

foz has 3 letters and COMPOUNDMIN is 1, so the loop tries breaks 1 and 2 (limit 3 - 1 + 1 = 3). At 1: f is no word and z does not start at position 1. At 2: fo is no word; then z matches at position 2, the expanded word is fo + o + (nothing) = foo, the break is 2 + 1 = 3, foo (flag C) is accepted as the first part, and the second part has 3 - 3 = 0 letters.

COMPOUNDMIN. A replacement starts at a break position, at most word.length - COMPOUNDMIN, so one that ends the word has at least COMPOUNDMIN letters. With the default 3 the trigger needs a 3-letter replacement: CHECKCOMPOUNDPATTERN d /X xyz with food/C and fooxyz fires it, while o /X z does not.

suggest(). Suggester checks candidates with a Hunspell whose findStem it overrides (Suggester.java:198-208), so the same path runs: suggest("fooxyz") and suggest("fozz") throw the same AssertionError with assertions on; with them off suggest("fozz") returns [foo].

Not reached by analysis. Stemmer and HunspellStemFilter, used inside analyzers, contain no compound-pattern checks; checkCompoundPatternReplacements and checkCompoundsAfter exist only in Hunspell.java.

Exposure and likelihood461 words
  • Who is hit. Code that calls Hunspell.spell or Hunspell.suggest (module lucene/analysis/common) and runs with assertions enabled and loads a dictionary with a CHECKCOMPOUNDPATTERN replacement whose beginchars are empty (on purpose, or by accident as in the Dutch dictionaries below) and checks a word that ends with that replacement (at least COMPOUNDMIN letters long). Assertions are typically on in test runs; Lucene's own build enables them for about 75% of test seeds.
  • What happens. A deterministic AssertionError (an Error, not an Exception) out of spell or suggest instead of an answer. With assertions off the repro, and the Dutch words below, get the same answers as C Hunspell 1.7.2 (the FULLSTRIP side case below is the exception).
  • Default vs opt-in. An opt-in API whose behaviour depends on the dictionary; assertions are off by default in the JVM. Stemming and analysis do not reach this code. No scale is needed: a 3-letter word.
  • How likely. Rare. No surveyed dictionary uses the replacement field on purpose, but two of the 191 public .aff files surveyed by the hunt (the LibreOffice dictionaries and wooorm/dictionaries at the commits Lucene's regression build pins, lucene/analysis/common/build.gradle:146-153), the two Dutch ones, end some CHECKCOMPOUNDPATTERN lines with a # comment (36 lines in LibreOffice nl_NL, 42 in wooorm nl). Lucene's parser takes the fourth whitespace-separated field as the replacement (CheckCompoundPattern.java:32, :45), as C Hunspell 1.7.2 does, so # becomes the replacement. One such line in nl_NL (line 659, and four in wooorm nl) has empty beginchars: CHECKCOMPOUNDPATTERN /Xf /Xf # moeder-dochter etc, and nl_NL's COMPOUNDMIN 0 is raised to 1 (Dictionary.java:438). With the stock nl_NL dictionary, spell("huis#"), "boek#", "water#" and "moeder#" (a compound-begin word followed by #) throw the same AssertionError with assertions on (main; huis# and boek# also on 10.6.0). With them off Lucene answers huis# false, boek# true, water# true, moeder# false, exactly as C Hunspell 1.7.2 does; with fix A nothing throws and the answers stay the same. It still needs a # at the end of the checked word (# is not in the Dutch WORDCHARS) and assertions on. Lucene's replacement fixtures all have non-empty beginchars, and neither Lucene's nor C Hunspell 1.7.2's fixtures combine a replacement with empty beginchars.
  • A side case, not claimed. With FULLSTRIP and a suffix that strips a whole root (for example SFX S y 0 . with y/CSX), the empty second part is read as that root minus the suffix, and Lucene with assertions off returns true for foz (main; a similar variant also on 10.5.2). C Hunspell 1.7.2 says false there but 1.3.2 says true, so no wrong production answer is claimed; with the guard Lucene answers false, like 1.7.2.

Minimal repro

One JUnit test, one method: a four-line affix file, one word and spell("foz"). Public API only, deterministic, single thread, about 2 s. It fails with assertions on and passes with them off, which is the bug.

TestCompoundPatternEmptyTail.java (JUnit, one test)39 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.analysis.hunspell;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCompoundPatternEmptyTail extends LuceneTestCase {

  public void testReplacementAtWordEnd() throws Exception {
    String aff =
        "COMPOUNDFLAG C\nCOMPOUNDMIN 1\nCHECKCOMPOUNDPATTERN 1\nCHECKCOMPOUNDPATTERN o /X z\n";
    String dic = "1\nfoo/C\n";
    Dictionary dictionary =
        new Dictionary(
            new ByteBuffersDirectory(),
            "dict",
            new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
            new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8)));
    // expected false (C Hunspell's answer); actual (-ea): AssertionError in WordStorage.lookupWord
    assertFalse(new Hunspell(dictionary).spell("foz"));
  }
}
Command
cp TestCompoundPatternEmptyTail.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestCompoundPatternEmptyTail -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (the test then passes: spell("foz") is false)
Expected
spell("foz") returns false, as C Hunspell 1.7.2 and Lucene with assertions off answer.
Observed
java.lang.AssertionError at WordStorage.lookupWord(WordStorage.java:107), reached from checkCompoundPatternReplacements, with assertions on.

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

More files, environment, full output292 words

The test sits in org.apache.lucene.analysis.hunspell, next to TestSpellChecking, by Lucene test convention; it uses only public API: Dictionary(Directory, String, InputStream, InputStream), Hunspell(Dictionary), Hunspell.spell(String) and ByteBuffersDirectory. It calls no random(), so the outcome does not depend on the seed.

Not shown by this one test, executed by the verifiers from the same root cause: suggest(), the default-COMPOUNDMIN form (CHECKCOMPOUNDPATTERN d /X xyz, fooxyz), and the stock Dutch dictionary case (spell("huis#"), see Who is affected), which needs a dictionary download.

As the regression test: the same test unchanged, for example as a new method of TestSpellChecking. It could also be written as a fixture set for doTest (.aff, .dic, .wrong), the form all 53 test methods of TestSpellChecking use. It fails on stock main with assertions on and passes with fix A with assertions on and off (gradle); :lucene:analysis:common:check -x test passes with fix A and this test in place.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.5.2 and 10.6.0; every release from 8.9.0 to 10.6.0 is affected with assertions on
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestCompoundPatternEmptyTail.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestCompoundPatternEmptyTail -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (the test then passes: spell("foz") is false)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (with =false the test passes; the same on 10.5.2 and 10.6.0)
TestCompoundPatternEmptyTail > testReplacementAtWordEnd FAILED
    java.lang.AssertionError
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:1CA89FF644B60EBF]:0)
        at org.apache.lucene.analysis.hunspell.WordStorage.lookupWord(WordStorage.java:107)
        at org.apache.lucene.analysis.hunspell.Dictionary.lookupWord(Dictionary.java:284)
        at org.apache.lucene.analysis.hunspell.Stemmer.doStem(Stemmer.java:229)
        at org.apache.lucene.analysis.hunspell.Hunspell.findStem(Hunspell.java:169)
        at org.apache.lucene.analysis.hunspell.Hunspell.checkCompoundsAfter(Hunspell.java:275)
        at org.apache.lucene.analysis.hunspell.Hunspell.checkCompoundPatternReplacements(Hunspell.java:260)
        at org.apache.lucene.analysis.hunspell.Hunspell.checkCompounds(Hunspell.java:241)
        at org.apache.lucene.analysis.hunspell.Hunspell.checkCompounds(Hunspell.java:157)
        at org.apache.lucene.analysis.hunspell.Hunspell.spellClean(Hunspell.java:100)
        at org.apache.lucene.analysis.hunspell.Hunspell.spell(Hunspell.java:86)
        at org.apache.lucene.analysis.hunspell.TestCompoundPatternEmptyTail.testReplacementAtWordEnd(TestCompoundPatternEmptyTail.java:37)

:lucene:analysis:common:test (FAILURE): 1 test, 1  failure

# C Hunspell 1.7.2 on the same dictionary: foz 0 (rejected), foo 1 (accepted)

Why it looks unintended

  • assert length > 0 is Lucene's own internal rule, added with guards that keep empty words out of the lookup; Lucene's own compound code breaks it.
  • Upstream fixed the same assertion, reached from another caller, as a bug (LUCENE-9833, #10872).
  • The input is legal: hunspell(5) shows empty beginchars written /flag, Lucene parses the rule, and C Hunspell answers false.
The full argument290 words
  • The contract is a boolean. spell promises "whether the given word's spelling is considered correct according to Hunspell rules" (Hunspell.java:71-73) and documents no exception.
  • The precondition is internal. LUCENE-9825 (4842e0c9cab, 2021) added assert length > 0 to WordStorage.lookupWord together with zero-length guards in other callers: if (length == 0) return in Stemmer.stem (today in Stemmer.analyze, which stem calls, Stemmer.java:90-92) and if (word.length() == 1) return; in ModifyingSuggester.tryRemovingChar (today ModifyingSuggester.java:296-297). In that PR's review the author wrote "We don't accept empty words for lookup anymore".
  • A related precedent. LUCENE-9833 (#10872, "Hunspell: AssertionError in WordStorage.lookupWord", PR #13; released in 8.9.0 and 9.0.0) is the same assertion reached from ModifyingSuggester.tryRep, where a REP candidate with a leading space split into an empty word part. Maintainers guarded that caller (the candidate is trimmed) and kept the assertion. That is an analogy, not proof of what this rule combination should do. The repro fails on every release that contains that fix.
  • Every other split keeps both parts non-empty (Hunspell.java:220-221); only the replacement path can make an empty part.
  • The input is legal. hunspell(5) gives the syntax CHECKCOMPOUNDPATTERN endchars[/flag] beginchars[/flag] [replacement] and the example CHECKCOMPOUNDPATTERN 0/x /y (empty beginchars with a flag). Lucene's parser accepts the line (Dictionary.java:458-464), Lucene's own fixture onlyincompound2.aff uses empty beginchars (0/B /A), and the class javadoc's list of unsupported features (Hunspell.java:35-44) does not mention it. C Hunspell 1.7.2 parses the test's dictionary and answers false; 1.3.2 also answers false on the hunt's two-root variant (foo/C, bar/C).
  • Never tested. No Lucene fixture combines a replacement with empty beginchars (checkcompoundpattern2.aff, checkcompoundpattern3.aff), nor, per the devil's advocate, do Hunspell 1.7.2's own fixtures.
The devil's-advocate check206 words

The strongest case for "intended" (the cross-model reviews in the hunt rated it weak, the 2026-11-01 re-review moderate; the cross-model fact check called it strong as a "noise / won't-fix" case and moderate as deliberate design): assertions are debug checks, and production gets C Hunspell's answer for the repro and the Dutch words checked; a 2026-10-29 audit classed it as assertion-only; no dictionary uses the combination on purpose, and it is untested in both Lucene and C Hunspell; the dictionary is trusted configuration; the Hunspell javadoc does not promise full compatibility with C Hunspell. The FULLSTRIP side case gives a different assertions-off answer, but the two C Hunspell versions disagree there.

Why it fails as an "intended" argument: nothing documents the combination as unsupported, and the broken invariant is internal to Lucene; a stock dictionary that Lucene's own regression build pins reaches it with an ordinary word plus #; maintainers guarded the same assertion at another caller rather than accept it; and the guard is one comparison that only drops candidates whose second part would be empty (the hunspell tests pass; in the FULLSTRIP side case it turns true into C Hunspell 1.7.2's false). Verdict: not intended; a missing bound check of low impact.

Suggested fix

Recommended A. Skip a replacement that leaves no second part

In checkCompoundPatternReplacements, skip a replacement whose break falls at the end of the expanded word: if (breakPos >= expanded.length) continue;. Validated: the repro passes with assertions on and off, and the hunspell tests and the module's static checks pass.

         int breakPos = pos + pattern.endLength();
+        if (breakPos >= expanded.length) {
+          continue; // the replacement ends the word: no second part
+        }
         Root<CharsRef> stem =
             findStem(expanded.chars, expanded.offset, breakPos, originalCase, context);
All options and trade-offs4 options ยท 491 words

Fix A was run through gradle in a scratch worktree of 645d4fe14a20; B, C and D are alternatives, not tested.

A. Skip a replacement that leaves no second part (validated by the checks) (recommended)

One guard in checkCompoundPatternReplacements (Hunspell.java:255), right after the break is computed.

         int breakPos = pos + pattern.endLength();
+        if (breakPos >= expanded.length) {
+          continue; // the replacement ends the word: no second part
+        }
         Root<CharsRef> stem =
             findStem(expanded.chars, expanded.offset, breakPos, originalCase, context);

Trade-offs

  • It only drops candidates whose second part would be empty. In the recorded FULLSTRIP side case that changes Lucene's assertions-off answer from true to false, like C Hunspell 1.7.2 (1.3.2 says true); with the stock Dutch dictionary it keeps the answers Lucene and C Hunspell 1.7.2 already give.
  • Sibling bug LFV-377 (expandReplacement at later compound boundaries) is a separate fix; with its fix alone this failure remains, with both the tests pass. On stock code the guard only matters at the first boundary: at later ones the expanded word still carries the earlier parts, so its second part is never empty and the guard compares in a different frame. After LFV-377's fix the expanded word is relative to the current part, and the guard compares in the same frame.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (2026-11-01): the repro fails on stock main with -Ptests.asserts=true and passes with =false; with A it passes with both.
  • With A and the repro in place: TestSpellChecking 53 tests, 0 failures; the whole org.apache.lucene.analysis.hunspell test package with assertions on, 47 classes, 159 tests, 0 failures, 13 skipped (TestPerformance, TestAllDictionaries and TestHunspellRepositoryTestCases need external dictionary checkouts; TestAllDictionaries.testMaxPrologueNeeded is also @Ignored); :lucene:analysis:common:check -x test (google-java-format, ecj lint, forbidden APIs) passes.
  • Earlier, the same guard: the finder's one-test class (two control assertions plus foz) and the 146 tests of Lucene's hunspell test classes on a9779ef3c193; an independent 5-test class 5/5; the cross-model reviewer's own tests; the devil's advocate's probes; the guard shadowed onto the 645d4fe14a20 jars.

B. Require at least COMPOUNDMIN letters after the break

Use expanded.length - breakPos < dictionary.compoundMin as the skip condition, like the regular loop.

Trade-offs

Changes which simplified forms are accepted when the second part is short; hunspell(5) itself notes that COMPOUNDMIN "doesn't work correctly with the compound word alternation", so the right bound is unclear. Suggested by the devil's advocate; not tested; needs C Hunspell comparisons.

C. Return early in checkCompoundsAfter when nothing is left

if (remainingLength == 0) return false; in checkCompoundsAfter, right after remainingLength is computed (Hunspell.java:272).

Trade-offs

Protects every caller that builds an empty tail, but hides where it came from. Not tested.

D. Let WordStorage.lookupWord return null for length 0

Replace the assertion with an early return null.

Trade-offs

Not recommended: it removes the check LUCENE-9825 added on purpose and LUCENE-9833 kept, so other callers' mistakes would go unnoticed.

Downstream note

Search indexing and query analysis are not affected, because HunspellStemFilter never runs this code; only applications that expose the spell-checker with such a dictionary (the stock Dutch one included) and run with assertions on would see the error.

How it was found

Non-formal: differential testing of Lucene's spell-checker against C Hunspell 1.7.2 over grammar-generated compounding dictionaries. Lucene threw AssertionError on 24 generated dictionaries; one was minimised by script and by hand to the one-test repro.

The method in full155 words

Method: non-formal (cross-implementation differential testing). A hunt unit generated small synthetic compounding dictionaries from a grammar (CHECKCOMPOUNDPATTERN with and without replacements, empty endchars or beginchars written with flags), spell-checked every word of up to 6 letters over a 3-letter alphabet plus 2- and 3-part concatenations with both Lucene and C Hunspell 1.7.2, and compared the answers. Lucene ran with the fix sketches of two already-known hunspell bugs applied, so that their differences would not drown new ones; the failure then reproduced on the stock jar.

Lucene threw AssertionError on one generated dictionary and on 23 more from further seeds. A minimiser and hand reduction turned one into a public-API JUnit test. It was then reviewed by a reviewer from another model family (OpenAI GPT-5.6) in the hunt and again on 2026-11-01, reproduced independently, argued against by an intended-behaviour devil's advocate, judged, traced through history, audited and cut to the one-test repro, re-verified on 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (including all 409 open PRs), the JIRA archive, the Lucene lists, downstream projects, other Hunspell implementations or the web (2026-10-27, refreshed 2026-11-01). Closest: LUCENE-9833 (#10872), the same assertion from another caller, fixed in 8.9.0 and 9.0.0. Not fixed upstream.

Searches5 searches

Closest items, none a report of this bug: #10872 (LUCENE-9833, the same assertion from ModifyingSuggester.tryRep, fixed at that caller; released in 8.9.0 and 9.0.0); apache/lucene-solr#2459 (LUCENE-9825, which added the assertion and guards in other callers); #10756 / apache/lucene-solr#2280 (LUCENE-9717, the feature; no discussion of an empty remainder); #10814 (LUCENE-9775, the last change to expandReplacement, origin of the sibling LFV-377); #16919 (merged 2026-10-28; its only hunspell change is in AffixCondition.java); #16422 (open, Dictionary.java only). In other implementations, helix-editor/spellbook#12 is a boundary-index crash in CHECKCOMPOUNDPATTERN in a Rust port, with a different trigger. Limits: keyword search cannot exclude a report worded without these terms; GitHub search does not index PR review comments (the relevant reviews were read directly); private channels are not visible. The stock Dutch dictionary trigger was found after these searches; no search targeted it.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene and apache/lucene-solr issues, PRs and commits; Elasticsearch, OpenSearch, Solr, Lucene.NET2026-10-27, 2026-11-01scripted prior-report searches (16 GitHub, 3 commit and 2 downstream queries per run) and 79 manual GitHub GraphQL searches: class, method and file names, symptoms, directive names, LUCENE-9717/9825/9833no report; LUCENE-9833 (#10872) is the same assertion from another caller
Open PRs2026-11-01touched files of all 409 open apache/lucene PRs, plus full file lists of the 3 PRs with more than 100 filesonly #16422 touches the hunspell package (Dictionary.java)
Mailing lists, JIRA archive, web2026-10-27, 2026-11-01dev@, java-user@, issues@ and commits@ (20 queries per run plus 24 direct queries); 4 JIRA-archive queries per run; 12 web queriesonly unrelated Hunspell threads
Other Hunspell implementations2026-11-01native Hunspell, nuspell, spylls and spellbook issue trackersspellbook#12, a different boundary crash in a Rust port
Upstream code2026-11-01commits to the hunspell package since 2026-10-20 on main and branch_10_6; current file contentsnot fixed: the same lines on main f815e679bf6c and branch_10_6

History

The missing check came with CHECKCOMPOUNDPATTERN support (LUCENE-9717, d0ae2bd2b9c, 2021-02-03) and was silent until LUCENE-9825 (4842e0c9cab, 2021-03-05) added the assertion. Every release with the spell-checking API fails with assertions on: 42 of 42 from 8.9.0 to 10.6.0, and main.

Versions tested8 rows

Root cause d0ae2bd2b9c "LUCENE-9717: Hunspell: support CHECKCOMPOUNDPATTERN (#2280)" (Peter Gromov, 2021-02-03; issue #10756) added checkCompoundPatternReplacements with breakPos = pos + pattern.endLength() and remainingLength = word.length - breakPos in checkCompoundsAfter; git blame -w -M -C -C still attributes both lines to it. The lookup of that time was an FST that found nothing for an empty word; with one probe line assert length > 0 added, that commit reaches the zero-length lookup and its parent does not.

Made visible by 4842e0c9cab "LUCENE-9825: Hunspell: reverse the "words" trie for faster word lookup/suggestions" (same author, 2021-03-05), which added WordStorage.lookupWord with assert length > 0; merged to main as f5012a4cdaf (2021-03-10). Both reached branch_8x together in c6b964a1157 (LUCENE-9687, 2021-05-28), first released in 8.9.0. Not a regression of a released version.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and passes with them off (gradle and standalone); passes with fix A
10.5.2, 10.6.0 affectedminimal repro fails with assertions on, passes with them off (release jars, re-run 2026-11-01)
8.9.0 first affectedfails with assertions on (WordStorage.lookupWord:96 through checkCompoundPatternReplacements), returns false with them off
8.10.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.1 affectedfail with assertions on (all 42 releases from 8.9.0 to 10.6.0 executed); 9.0.0 returns false with them off
8.8.0, 8.8.1, 8.8.2 no APIno Hunspell / SpellChecker class
d0ae2bd2b9c (LUCENE-9717) vs parent 6509a3003cf silentboth pass; with a length probe the commit reaches the zero-length lookup, its parent does not
4842e0c9cab (LUCENE-9825) vs parent 99a4bbf3a0a bisectthe commit fails with assertions on; its parent passes
upstream main f815e679bf6c, branch_10_6 readthe same lines (2026-11-01)

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, reviews by a reviewer from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a history step, a re-verification and fact checks on 645d4fe14a20 all found it real; the reviews in the hunt rated the intended argument weak, the 2026-11-01 re-review moderate.

Checks and verdicts368 words

Everyone agrees on the root cause, the one-line guard and the low impact: for the repro and the Dutch words checked, a failure only with assertions on, with C Hunspell's answer when they are off.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedDifferential testing against C Hunspell 1.7.2; the JUnit fails with assertions on on main, 10.5.2 and 9.12.3, passes with them off and with the guard.
cross-model review, refute lens (OpenAI GPT-5.6, 2026-10-27) real, highOwn test fails with assertions on and passes with them off; root cause agreed; intended argument weak.
independent reproducer realOwn 5-test class: 3 fail with assertions on (foz, the default-COMPOUNDMIN fooxyz, suggest("fooxyz")), 2 controls pass, all pass with them off; C Hunspell 1.3.2 and 1.7.2 answer false; the LFV-377 fix alone leaves it.
cross-model review, intended lens (OpenAI GPT-5.6, 2026-10-27) real, highOwn tests fail with assertions on and pass with them off and with the guard; intended argument weak.
intended-behaviour devil's advocate real, highFound the LUCENE-9825 guards and the LUCENE-9833 precedent, the COMPOUNDMIN and /flag preconditions, suggest("fozz") and the FULLSTRIP side case.
adjudication confirmed, highRe-ran the finder's repro (fails with assertions on, passes with them off) and the independent test (3 of 5 fail); impact minor.
cross-model re-review, refute lens (OpenAI GPT-5.6, 2026-11-01) real, highOwn test fails with assertions on and passes with them off (its test re-run on 645d4fe14a20); intended argument moderate: "normal -da deployments return the correct false result, making this low-priority assertion noise rather than a production correctness failure".
history and re-verification still reproduces42 of 42 releases fail with assertions on; on 2026-11-01 the one-test repro fails on 645d4fe14a20, 10.5.2 and 10.6.0 with assertions on and passes with them off; fix A validated with gradle.
fact checks (2026-11-01) real; wider triggerAn independent fact check re-ran the repro (standalone and gradle; main, 10.5.2, 10.6.0) and fix A, and found that the stock Dutch dictionary reaches the assertion (spell("huis#")); a cross-model fact check (OpenAI GPT-5.6) found the bug real as described and asked for narrower wording. Their corrections are applied.

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,305 words

Finding record

LFV-713, snapshot of 2026-11-06

Registry title
Hunspell spell() and suggest() throw AssertionError under -ea when a word ends with a CHECKCOMPOUNDPATTERN replacement whose beginchars are empty, because the compound check looks up a zero-length second part
Area
analysis / hunspell (Hunspell.spell and suggest compound checking, CHECKCOMPOUNDPATTERN replacements)
Priority
Low, rank 1179: This released AssertionError fires only under -ea and only for a CHECKCOMPOUNDPATTERN replacement with empty beginchars that only the stock Dutch dictionaries carry, by accident.
Impact class
minor
Impact note
Only applications that call the opt-in Hunspell spell-checker (Hunspell.spell, and suggest, which re-checks candidates the same way) with JVM assertions enabled (-ea, typically a test suite) and a dictionary that combines a CHECKCOMPOUNDPATTERN replacement with empty beginchars (written '/flag'; no surveyed public dictionary uses the replacement field on purpose, but the two Dutch ones, LibreOffice nl_NL and wooorm nl, end some CHECKCOMPOUNDPATTERN lines with a '# comment' that Lucene parses as the replacement '#', and nl_NL line 659 has empty beginchars, so with the stock nl_NL dictionary spell("huis#") throws the same AssertionError under -ea) are hit, for words that end with a replacement at least COMPOUNDMIN (default 3) long: the call throws java.lang.AssertionError instead of answering; with -da (production) the repro dictionaries get the correct answer false, stemming and analysis (HunspellStemFilter) never reach this code, no scale is needed, and every release from 8.9.0 to 10.6.0 has it (all executed).
Summary
You load a Hunspell dictionary with a compound rule such as 'CHECKCOMPOUNDPATTERN o /X z' (a simplified compound form whose second part may begin with anything: hunspell(5) allows empty beginchars with a flag and an optional replacement) and call Hunspell.spell("foz"), and expect true or false; C Hunspell 1.3.2 and 1.7.2 both say false. Instead, with assertions enabled, spell() and suggest() throw java.lang.AssertionError from WordStorage.lookupWord ('assert length > 0'). The cause: checkCompoundPatternReplacements expands the final 'z' to 'o' plus empty beginchars and puts the break right after 'foo', but never checks that a second part remains, so checkCompoundsAfter looks up a second part of length 0; with -da that lookup finds nothing and spell() returns false for the repro.
Introduced
d0ae2bd2b9c9, LUCENE-9717 'Hunspell: support CHECKCOMPOUNDPATTERN' (apache/lucene-solr#2280, by donnerpeter; issue apache/lucene#10756), the root cause: it added checkCompoundPatternReplacements ('int breakPos = pos + pattern.endLength()') and 'remainingLength = word.length - breakPos' in checkCompoundsAfter with no check that a second part remains [read: git blame -w -M -C -C still attributes both lines to it] [exec: the hunspell sources of that commit, with one added 'assert length > 0' probe line in Dictionary.lookupWord, reach the zero-length lookup; its parent 6509a3003cf with the same probe does not]. Silent at that commit, because the FST lookup returns no entry for an empty word. Made visible as an AssertionError by LUCENE-9825 (4842e0c9cab, 2021-03-05, by donnerpeter, apache/lucene-solr#2459, issue apache/lucene#10864), which added WordStorage.lookupWord with 'assert length > 0' and reached main through the merge f5012a4cdaf (apache/lucene#2, 2021-03-10) [exec: f5012a4cdaf fails, 44833dc5751 just before it passes]. Reached 8.x through the branch_8x backport c6b964a1157 (apache/lucene-solr#2499, LUCENE-9687). Both steps predate every release, so it is not a regression of a released version (2021-02-03 (root cause, silent); 2021-03-10 (AssertionError visible on main); 2021-05-28 (8.x backport with both))
First affected version
8.9.0 [exec] (the first release with the spell-checking API, through the 8.x backport; 8.8.2 has no Hunspell class [exec]); on the main line 9.0.0 [exec]
Affected versions
every release that has the spell-checking API: 8.9.0, 8.10.0, 8.10.1, 8.11.0 - 8.11.4, 9.0.0 - 9.12.3 and 10.0.0 - 10.6.0 (42 releases), all executed with -ea and all failing [exec], and main a9779ef3c193 and 645d4fe14a20 [exec]; with -da every executed run (8.9.0, 9.0.0, 10.5.2, 10.6.0, main) returns the correct false for the repro [exec]. Not affected: 8.8.2 and older (no spell-checking API) [exec 8.8.2]. Not fixed upstream: checkCompoundPatternReplacements is unchanged and lookupWord still asserts length > 0 on upstream main a95c754ee003, branch_10x and branch_10_6 (2026-10-27) [read]. All runs on Linux aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-27, read-only, 178 queries: three scripted prior-report searches over GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr, Lucene.NET), the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 45 manual GitHub GraphQL searches, including a listing of all 218 apache/lucene items that mention hunspell and the native Hunspell, nuspell and spylls projects; the touched files of all 410 open apache/lucene PRs; local git history; 16 hits judged, none the same bug; the independent reproduction's and the intended-behaviour check's searches agree). Closest items: #10872 (LUCENE-9833, PR #13, fixed in 9.0.0), the same AssertionError in WordStorage.lookupWord reached from another caller (ModifyingSuggester.tryRep with an empty REP candidate) and fixed at that caller only, a precedent and not this bug (the repro fails on every release that contains that fix); apache/lucene-solr#2459 (LUCENE-9825), which added the assert together with zero-length guards in Stemmer and ModifyingSuggester (the author's review note: 'We don't accept empty words for lookup anymore') but not in this caller; #10756 (LUCENE-9717, apache/lucene-solr#2280), the feature, with no review discussion and no fixture that combines a replacement with empty beginchars; #16422, the only open PR in the hunspell package apart from the branch_10x decommission #16911 (Dictionary.java only). Not fixed upstream. Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main 645d4fe14a20 (gradle: the minimal repro with -Ptests.asserts=true and =false, stock and with fix A; with fix A, TestSpellChecking, the whole org.apache.lucene.analysis.hunspell test package and :lucene:analysis:common:check -x test; standalone runs with assertions on and off, and fix A shadowed onto the main jars), the 10.5.2 and 10.6.0 release jars (SHA-1 checked; assertions on and off), and, by the hunt, its verifiers and the history step, main a9779ef3c193 and ea57360912ed, all 42 releases from 8.9.0 to 10.6.0 with assertions on, 8.8.0-8.8.2 (no spell-checking API), and the commits 6509a3003cf, d0ae2bd2b9c, 44833dc5751, 99a4bbf3a0a, 4842e0c9cab and f5012a4cdaf. References: C Hunspell 1.7.2 (re-run on the test's dictionary) and 1.3.2. Upstream main f815e679bf6c and branch_10_6 91eaab4fb298 have the same lines (read, 2026-11-01).

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java at a9779ef3c193: :71-74 spell javadoc ('@return whether the given word's spelling is considered correct according to Hunspell rules'), :213-247 checkCompounds (:220-221 the break loop keeps both parts at least compoundMin long, :241 the call into the replacement path), :249-267 checkCompoundPatternReplacements (:255 breakPos = pos + pattern.endLength(), no remainder check), :269-286 checkCompoundsAfter (:272 remainingLength = word.length - breakPos, :275 findStem)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/CheckCompoundPattern.java at a9779ef3c193: :29-45 constructor (split on whitespace, beginChars empty when the field is '/flag', replacement = parts[3]), :94-103 expandReplacement (prefix + endChars + beginChars + rest, null without a replacement)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/WordStorage.java at a9779ef3c193: :106-107 lookupWord 'assert length > 0'
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:71-73, :150-161, :163-184, :213-247, :249-267, :269-286 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/CheckCompoundPattern.java:29-46, :94-107 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/WordStorage.java:106-118; lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:283-285, :438 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java:227-229; lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Suggester.java:198-208 (at 645d4fe14a20)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundpattern2.aff:5, checkcompoundpattern2.good:2 (fozar) (at 645d4fe14a20)
Who is affected
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundpattern2.aff:5-6, checkcompoundpattern3.aff:5 (at 645d4fe14a20)
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248 (at 645d4fe14a20)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-44, :71-73, :220-221; lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:458-464 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java:90-92; lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/ModifyingSuggester.java:296-297 (at 645d4fe14a20)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/onlyincompound2.aff:11-12, checkcompoundpattern2.aff, checkcompoundpattern3.aff (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.