All findings · Lucene finding · LFV-377 · hunspell-compound-pattern-replacement-loses-offset

Hunspell spell-checker: simplified compound joins after the first part are rejected or overflow the stack

With a Hunspell dictionary that allows simplified compound joins (CHECKCOMPOUNDPATTERN ff f ff), Hunspell.spell accepts schiffahrt but rejects dampfschiffahrt, and spell("schiffschiffahrt") throws StackOverflowError. After the first part, the code reads the simplified join from the start of the whole word; 8.9.0 to 10.6.0 are affected (10.6.0 by reading).

Summary

Priority
Low, rank 463Hunspell rejects valid simplified compounds and can throw StackOverflowError, but no surveyed public dictionary uses the CHECKCOMPOUNDPATTERN replacement field.
Impact
silent wrong results
Since
8.9.0 (LUCENE-9775, 2021)introduced 2021-02-17 · about 5 years
Affected
8.9.0 to 10.6.0 (10.6.0 by reading) and main; 11 releases run
Status
documented
Found by
differential testing against C Hunspell 1.7.2

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

Hunspell splits a compound left to right and checks the rest of the word as a slice that starts at some position. To try a simplified join in that slice, the code writes out the long form in a new array that starts with the whole word, then reads the next part from position 0 instead of from where the slice starts.

The word dampfschiffahrt split into dampf, schiff and fahrt, where the written ff at the second join stands for ff|f. The code writes out dampfschifffahrt and reads the next part from position 0 (dampfs, not a word) instead of position 5 (schiff), so the word is rejected; in schiffschiffahrt it reads schiff again at every level until StackOverflowError.
The German example: dampfschiffahrt split as dampf + schiff|fahrt, and which letters the code reads for the part after the first join. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Load a dictionary with CHECKCOMPOUNDPATTERN ff f ff, the Hunspell ChangeLog's own example: a written ff at a compound join stands for ff|f, as in pre-reform German Schiffahrt = Schiff + Fahrt. Words: dampf, schiff, fahrt, donau. spell("schiffahrt") returns true.
  2. Put a part in front: spell("dampfschiffahrt"), dampf + schiff|fahrt. You expect true, as native Hunspell 1.3.2 and 1.7.2 return. You get false, with no error; donaudampfschiffahrt too.
  3. spell("schiffschiffahrt") returns no verdict at all: it throws StackOverflowError, and so does suggest. Native Hunspell just answers "misspelled".
  4. Why: to try the simplified join in the rest schiffahrt, which starts at position 5, the code writes out the long form dampfschifffahrt but reads the next part from position 0: dampfs instead of schiff. In schiffschiffahrt it reads schiff, the first part, again, and the rest grows by one letter at every level.
  5. Only the opt-in spell-checker is hit, and only with dictionaries that use this replacement field (none of 191 surveyed public ones does). Stemming, indexing and search are unaffected. Every release from 8.9.0 to 10.6.0 (10.6.0 by reading).

Who is affected

Only applications that call the opt-in spell-checker (Hunspell.spell, suggest) with a dictionary that uses the replacement field of CHECKCOMPOUNDPATTERN (simplified joins, as in pre-reform German). None of 191 surveyed public dictionaries does. Then valid compounds whose simplified join comes after the first part are reported as misspelled, and some words throw StackOverflowError. Stemming, indexing and search are unaffected.

Mechanism, terms and numbers761 words

CHECKCOMPOUNDPATTERN endchars beginchars replacement lets a Hunspell dictionary accept a simplified spelling at a compound join: with ff f ff, a written ff stands for ff|f, as in pre-reform German Schiffahrt (Schiff + Fahrt). Lucene's Hunspell.spell splits a compound left to right and works on the rest of the word as a slice of the same array. To try a simplified join in that slice, CheckCompoundPattern.expandReplacement writes the long form into a new array that starts with the whole word (kept so the FORCEUCASE check can see the first letter) but returns it with offset 0, while its callers count positions from the start of the slice. At the first join the two agree; at a later join the next part is read from the start of the whole word. So dampfschiffahrt and donaudampfschiffahrt are rejected (native Hunspell accepts both), and schiffschiffahrt, or the 3-letter auu with CHECKCOMPOUNDPATTERN u u u, makes spell() and suggest() throw StackOverflowError. Only the opt-in spell-checker is affected, with dictionaries that use the replacement field, in every release from 8.9.0 to 10.6.0 (10.6.0 by reading); the fix is one changed return in one method.

Terms

  • Compound checking: when a word is not in the dictionary, Hunspell.spell tries to split it into dictionary words that may be compounded (here, words with the COMPOUNDFLAG). It finds a first part, then splits the rest the same way, recursively, so a word can have any number of parts (COMPOUNDWORDMAX defaults to unlimited).
  • Simplified join (CHECKCOMPOUNDPATTERN endchars beginchars replacement, hunspell(5): "The optional replacement parameter allows simplified compound form"): with ff f ff, the two letters ff written at a join stand for ff|f, so schiffahrt is read as schiff + fahrt.
  • The slice: from the second part on, the code works on the rest of the word as a slice of the same array, an offset (where the rest starts) and a length.

The bug

  1. For each position in the current slice, checkCompounds also tries the simplified joins (Hunspell.java:241): expandReplacement writes the long form, new String(word.chars, 0, word.offset + breakPos) + endChars + beginChars + rest (CheckCompoundPattern.java:96-100). The string starts at position 0 of the whole word, because LUCENE-9775 kept the earlier parts for the FORCEUCASE check, and new CharsRef(String) gives it offset 0.
  2. The caller looks up the next part as findStem(expanded.chars, expanded.offset, breakPos, ...) with breakPos = pos + endLength, a length counted inside the slice (Hunspell.java:255-257); checkCompoundsAfter then reads or recurses on the rest at offset + breakPos (:270-275, :284).
  3. At the first join the slice starts at 0 and all is right. At a later join the next part is read from the start of the whole word, and the rest re-includes the earlier parts.
  4. If those first letters are not a word, that split fails, and for dampfschiffahrt every other split fails too, so the valid compound is rejected. If they happen to be one (the first part again) and the pattern matches again, the rest is longer than before; nothing limits the depth by default (compoundMax = Integer.MAX_VALUE, Dictionary.java:152, checked at Hunspell.java:214), so the recursion ends in StackOverflowError.

The numbers

dampfschiffahrt (15 letters, positions 0-14) with the dictionary dampf, schiff, fahrt, donau:

stepslice (offset, length)what happensmainwith the fix
1dampfschiffahrt (0, 15)first part dampf; rest schiffahrt (5, 10)samesame
2schiffahrt (5, 10)ff at position 4 of the slice may be ff|f: long form dampfschifffahrtnext part read as [0, 6) = dampfs: no word[5, 11) = schiff, then [11, 16) = fahrt
3rejectedaccepted

schiffschiffahrt (16 letters): at the second join the long form is schiffschifffahrt (17); the next part is read as [0, 6) = schiff, the first part again, and the rest is [6, 17) = schifffahrt, 11 letters instead of 10. The next level gives schiffffahrt (12), then 13, and so on, one more f each time, until the stack overflows. An executed trace of the lookups shows exactly these letters, on main and with the fix ([6, 12) = schiff, [12, 17) = fahrt: accepted).

The same shape gives a 3-letter overflow: one word au with CHECKCOMPOUNDPATTERN u u u makes spell("auu") find au again at every level. C Hunspell expands the current part in a copy and recurses on its substring (affixmgr.cxx:1641-1646, :2084), so its positions stay relative to the part.

Exposure and likelihood360 words
  • Who. Callers of the spell-checker API: Hunspell.spell, Hunspell.suggest, and Suggester (a public class from 9.5.0), which creates an internal speller (Suggester.java:184-185) and validates candidates with its spell (:215, :298, :301); before 9.5.0 the same code is reached through Hunspell.spell and suggest. Executed: spell and suggest both throw.
  • Opt-in twice. The spell-checker is a separate API that no analyzer calls, and the dictionary must use the optional replacement field of CHECKCOMPOUNDPATTERN. Compounds of three or more parts are the default once compounding is on, since COMPOUNDWORDMAX is unlimited by default.
  • Real dictionaries. None of 191 public .aff files surveyed (LibreOffice and wooorm/dictionaries) uses the replacement field. A dictionary author reported using it to native Hunspell (hunspell/hunspell#950, 2023, a Sanskrit rule). Both documented examples, the ChangeLog's ff f ff and the u/B u/B u of native's Telugu test checkcompoundpattern4, reach the overflow.
  • Effect. Valid compounds whose simplified join is not the first join are reported as misspelled, silently. Words in which the expanded letters line up with an earlier part throw StackOverflowError: an Error, so catch (Exception e) does not catch it, and the default suggest time limit (250 ms, return partial result) does not stop it. Each call fails on its own: after the overflows, the same Hunspell instance answered five checked words like a fresh instance.
  • Not affected. HunspellStemFilter uses Stemmer.stem, which does not compound (HunspellStemFilter.java:86, :111), and getRoots returns no roots for compounds (Hunspell.java:300). Executed: the stem filter returns the triggering words unchanged with all three triggering dictionaries, and getRoots("schiffschiffahrt") is [].
  • Workarounds. A small COMPOUNDWORDMAX in the dictionary stops the overflow but not the false negatives (with COMPOUNDWORDMAX 3, barfozar is still rejected); a large one is finite but costly (with 1024, spell("auu") made about a million stem lookups and allocated about 1.5 GB); a checkCanceled hook with a budget can stop the recursion, since it runs on every stem lookup (Hunspell.java:165).
  • Likelihood: rare. It needs such a dictionary; then ordinary three-part compounds hit the false negative. No scale is needed: one word triggers it.

Minimal repro

The test shows that a CHECKCOMPOUNDPATTERN replacement after the first compound part is checked from the start of the word (rule u u u, root au/x), so the recursion never ends.

TestCompoundPatternReplacementRecursion.java (JUnit test)38 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 java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCompoundPatternReplacementRecursion extends LuceneTestCase {

  public void testReplacementAfterFirstPart() throws Exception {
    String aff = "COMPOUNDFLAG x\nCOMPOUNDMIN 1\nCHECKCOMPOUNDPATTERN 1\nCHECKCOMPOUNDPATTERN u u u\n";
    String dic = "1\nau/x\n";
    Dictionary dictionary =
        new Dictionary(
            new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
            List.of(new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8))),
            false,
            SortingStrategy.inMemory());
    // expected: false ("auu" is no compound of "au"); actual: StackOverflowError
    assertFalse(new Hunspell(dictionary).spell("auu"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-377/, 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:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$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.analysis.hunspell.TestCompoundPatternReplacementRecursion
Expected vs actual
Expected: spell("auu") returns false (C Hunspell: misspelled, suggestion au). Actual: StackOverflowError.

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

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.StackOverflowError
       org.apache.lucene.analysis.hunspell.Dictionary.hasFlag(Dictionary.java:1506)
       org.apache.lucene.analysis.hunspell.Hunspell.acceptCase(Hunspell.java:187)
       org.apache.lucene.analysis.hunspell.Hunspell.lambda$findStem$0(Hunspell.java:175)
       org.apache.lucene.analysis.hunspell.Stemmer.doStem(Stemmer.java:245)
       org.apache.lucene.analysis.hunspell.Hunspell.findStem(Hunspell.java:169)
       org.apache.lucene.analysis.hunspell.Hunspell.checkCompoundPatternReplacements(Hunspell.java:257)
       org.apache.lucene.analysis.hunspell.Hunspell.checkCompounds(Hunspell.java:241)
       org.apache.lucene.analysis.hunspell.Hunspell.checkCompoundsAfter(Hunspell.java:285)
       org.apache.lucene.analysis.hunspell.Hunspell.checkCompoundPatternReplacements(Hunspell.java:260)
       [...]

Why it looks unintended

  • hunspell(5): "The optional replacement parameter allows simplified compound form", with no limit on where the join is; native Hunspell accepts dampfschiffahrt and answers every overflow word.
  • The feature commit handled later joins; a one-line FORCEUCASE change (LUCENE-9775) broke them, and reverting that line alone restores the old results.
  • spell() documents a boolean result; nothing documents a StackOverflowError as expected.
The full argument363 words
  • Contract. hunspell(5): "CHECKCOMPOUNDPATTERN endchars[/flag] beginchars[/flag] [replacement] ... The optional replacement parameter allows simplified compound form." No limit on the position or the number of parts, and "COMPOUNDWORDMAX ... (Default is unlimited.)". Hunspell.spell: "@return whether the given word's spelling is considered correct according to Hunspell rules" (Hunspell.java:71-74): a boolean result; not a formal promise that no Error escapes, but no limitation or expected failure is documented. The class "can be used in place of native Hunspell for many languages" (:35-36); its examples of unsupported cases ("For example:" Hungarian, non-BMP characters, PHONE, :37-44) do not mention this directive. hunspell(5) warns right after the directive that "COMPOUNDMIN doesn't work correctly with the compound word alternation" (man/hunspell.5:494-495); that does not explain this bug, since every repro uses COMPOUNDMIN 1.
  • Tests. Lucene's ported fixtures checkcompoundpattern2 and checkcompoundpattern3 list only two-part simplified forms (fozar, fur, bozan), so no test reaches a later join (TestSpellChecking.java:97-106). TestHunspellRepositoryTestCases does not exclude native's checkcompoundpattern4 (:38-46), so Lucene expects to conform on it; that fixture's own words are two-part and pass (checked with the verifiers' own tests on that dictionary; the repository-wide test needs a Hunspell source checkout and was not run).
  • History. The feature commit (LUCENE-9717, d0ae2bd2b9c, 2021-02-03) built the long form relative to the part and handled later joins (barfozar, barfur accepted, spell("auu") false). Two weeks later LUCENE-9775 (902cb93db22) changed only that expression in expandReplacement so the FORCEUCASE check could see the word's first letter, and left the offset at 0; the author's review note says "Include all compounds parts from the very beginning for the case check to work later". That commit with only this line reverted is correct again. Nothing in it mentions simplified joins after the first part.
  • Reference. Native Hunspell 1.3.2 and 1.7.2 accept barfozar, barfur, barbozan, dampfschiffahrt and donaudampfschiffahrt, answer auu with "misspelled, suggestion au", and never crash on the words tried.
  • Precedents. LUCENE-9729 fixed an earlier offset/length slip in the same class; native Hunspell fixed CHECKCOMPOUNDPATTERN failing in compounds of more than two parts as a bug in 2010 (hunspell/hunspell#126).
The devil's-advocate check130 words

The strongest case: the class promises to replace native Hunspell only for many languages, the ported fixtures show only two-part simplified forms, and native Hunspell is itself position-dependent (it rejects fozarbar and schiffschiffahrt), so behaviour beyond two parts is unspecified.

Why it fails: the manual sets no position or count limit; the original code handled later joins, and the loss came from an unrelated FORCEUCASE change; the javadoc's examples of unsupported cases do not mention this directive; the asserted cases are exactly those where native Hunspell, the manual and the fixed code agree; and nothing documents a StackOverflowError from spell() on a 16-letter, or 3-letter, word as expected. Every reviewer that rated the intended-behaviour case rated it weak; the refute review gave no verdict. Verdict: not intended, a defect.

Suggested fix

Recommended A: keep the earlier parts, return the current part's offset (one changed return)

In CheckCompoundPattern.expandReplacement, keep the earlier parts in the long-form array, since the FORCEUCASE check reads the word's first letter, but return it with the current part's offset: new CharsRef(expanded.toCharArray(), word.offset, expanded.length() - word.offset). The repros then pass, the hunspell test package runs 177 tests with 0 failures (164 pass, 13 skipped) with assertions on and off, and lint passes.

-      return new CharsRef(
+      String expanded =
           new String(word.chars, 0, word.offset + breakPos)
               + endChars
               + beginChars
-              + word.subSequence(breakPos + replacement.length(), word.length));
+              + word.subSequence(breakPos + replacement.length(), word.length);
+      // keep the preceding compound parts in the array, but keep positions relative to word.offset
+      return new CharsRef(expanded.toCharArray(), word.offset, expanded.length() - word.offset);
All options and trade-offs4 options ยท 786 words

One fix was built and run in a scratch worktree at a9779ef3c193; the finder and the independent reproducer produced textually different patches that make the same correction. It keeps what LUCENE-9775 wanted (the array still starts with the whole word) and makes every part-relative reader see the current part at the offset it expects. It does not bound the recursion for every possible pattern (option C). For the regression test, the primary test class covers both symptoms; the 3 fixture lines are smaller but cover only the false negatives.

A: keep the earlier parts, return the current part's offset (one changed return) (validated by the checks) (recommended)

Build the long form as today, then wrap it with word.offset instead of 0.

-      return new CharsRef(
+      String expanded =
           new String(word.chars, 0, word.offset + breakPos)
               + endChars
               + beginChars
-              + word.subSequence(breakPos + replacement.length(), word.length));
+              + word.subSequence(breakPos + replacement.length(), word.length);
+      // keep the preceding compound parts in the array, but keep positions relative to word.offset
+      return new CharsRef(expanded.toCharArray(), word.offset, expanded.length() - word.offset);

Trade-offs

Changes verdicts only for dictionaries that use the replacement field: valid later-join simplified forms are accepted, and the overflow words of these examples return a verdict. Native Hunspell's verdicts depend on the position and the pattern in ways hunspell(5) does not describe: with the checkcompoundpattern2/3 patterns it rejects fozarbar, furbar and bozanbar; with ff f ff it accepts schiffahrtdampf but rejects schiffschiffahrt. The fix accepts all of these words, so it also accepts some words native rejects (furfur, bozanbozan, schiffschiffahrt); stock Lucene already accepts first-join forms native rejects (fozarbar, furbar). It allocates what today's code allocates (new CharsRef(String) also copies the string). Not benchmarked.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: stock plus the fixture lines fails 16 of 72 (five repro classes and TestSpellChecking), with assertions on and off; the primary test alone fails 2 of 3 both ways.
  • With the fix and the fixture lines: --tests 'org.apache.lucene.analysis.hunspell.*' plus the five repro classes, 51 classes, 177 tests, 0 failures, 13 skipped (TestPerformance 10 and TestAllDictionaries 1 need external dictionaries or word corpora, TestAllDictionaries 1 is @Ignored, TestHunspellRepositoryTestCases 1 needs a Hunspell source checkout), so 164 ran and passed, with assertions on and off; TestSpellChecking's forceucase, checkcompoundpattern and simplifiedtriple cases included.
  • :lucene:analysis:common:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the fix, the fixture lines and the primary test.
  • Earlier, with the fix shadowed on ea57360912ed: the finder's 3 of 3, the independent reproducer's 7 of 7, the devil's advocate's tests and Lucene's hunspell test classes 146 of 146 pass; the lookup trace reads schiff at the right offset.
  • Later, by probe on a9779ef3c193, assertions on and off: a simplified join followed by a regular part (dampfschiffahrtdampf, donaudampfschiffahrtdampf) is false on main and true with the fix, as native Hunspell 1.3.2 and 1.7.2 answer. No test covers a pattern for which the fix does not shrink the tail (option C).

B: make the long form part-relative again and give FORCEUCASE the word's start another way

Revert to word.subSequence(0, breakPos) + ... (offset 0 is then right) and pass the whole word's first character, or the original array, to hasForceUCaseProblem separately.

Trade-offs

Same verdicts as A in principle, a larger diff in Hunspell.java. Not run. Reverting the line alone is not enough: for a simplified compound the FORCEUCASE check would read the current part's first letter (Hunspell.java:279, :290).

C (defence only): bound the recursion

Stop when the remaining tail is not shorter than the current slice, or cap the depth by the word length.

Trade-offs

Prevents the StackOverflowError but not the false negatives. Not run. Not made redundant by A: after A the tail left after a simplified join is shorter than the slice only when the match position plus the replacement's length exceeds the length of beginchars, and the parser allows any lengths (CheckCompoundPattern.java:29-45). Executed: CHECKCOMPOUNDPATTERN x yx x with COMPOUNDMIN 1 and the words yx, z makes spell("yxz") throw StackOverflowError on main and with A, assertions on and off. The match is at the first join, where the offset is right, so this is a separate, contrived edge; native Hunspell 1.3.2 and 1.7.2 answer "misspelled".

Workaround until a release has the fix

Add a small COMPOUNDWORDMAX to the dictionary, or pass a checkCanceled hook with a budget to the Hunspell constructor.

Trade-offs

Stops the overflow (executed with COMPOUNDWORDMAX 3), not the false negatives. A large value is finite but costly: with 1024, spell("auu") made about a million stem lookups and allocated about 1.5 GB.

Downstream note

Downstream: a spell-checking service that loads such a dictionary and checks words its users type can see single spell or suggest calls fail with StackOverflowError, an Error that catch (Exception e) does not catch; indexing and search through HunspellStemFilter are not affected.

How it was found

Differential testing: the stock jar's Hunspell.spell was compared with C Hunspell 1.7.2 on words generated from the 131 Hunspell 1.7.2 conformance fixtures. Lucene threw StackOverflowError on 11 generated words of the Telugu checkcompoundpattern4 dictionary; code reading found the offset, and three-part words on Lucene's own fixtures showed the false misspellings.

The method in full156 words

Method: differential testing against another implementation (the hunt counts this as testing, not as checking against an exact reference semantics).

  • A generator built word lists from the 131 Hunspell 1.7.2 conformance fixtures: fixture words, every generated form, case variants, edits, two-part and three-part concatenations. A small C driver called Hunspell_spell per word, and the results were compared with the stock Lucene jar.
  • On the checkcompoundpattern4 (Telugu sandhi) dictionary, Lucene threw StackOverflowError on 11 words, such as pErupErupEru and sUrya-pEru; the fixture's own two-part words pass.
  • Code reading of expandReplacement and its callers found the offset; a shadow fix confirmed it. Three-part words on Lucene's own checkcompoundpattern2/3 content (barfozar, barfur, barbozan) showed the silent false negatives, and the finder reduced both to a public-API JUnit.
  • An independent reproducer, two cross-model stages, a devil's advocate (who added the ChangeLog's German example), an adjudication and a credibility audit then checked it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (159 query records over Lucene issues, PRs, commits, JIRA, the mailing lists, downstream trackers and the web, 2026-10-26). The only upstream items about this code are its own history, LUCENE-9717 (the feature) and LUCENE-9775 (the change that introduced the bug); no open PR touches the two files.

Searches4 searches

Registry verdict new: no report, issue, PR or discussion of this bug by anyone else was found. Related, not reports: #10814 / lucene-solr#2375 (LUCENE-9775, introduced it; no discussion of the offset or of three or more parts), #10756 / lucene-solr#2280 (LUCENE-9717, the feature; its ported fixtures are two-part), #10768 (LUCENE-9729, an earlier offset fix in the same class), native hunspell/hunspell#126 (2010, an analogous multi-part bug in the C++ prohibition patterns) and hunspell/hunspell#950 (2023, a dictionary author using the replacement field). Gaps: keyword-based searches; private channels are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; web2026-10-26a scripted prior-report search, 37 queriesonly the feature's own history
dev@, java-user@, issues@, commits@; JIRA (LUCENE)2026-10-26a scripted prior-report search, 24 queriesonly LUCENE-9717, LUCENE-9775, LUCENE-9729 and notification mirrors
GitHub (apache/lucene, apache/lucene-solr, native hunspell, downstream)2026-10-2694 read-only GraphQL searches, including all 218 apache/lucene items that mention hunspell; 43 items read in fullno report; 17 hits judged, none the same bug
Open PRs and fix status2026-10-26touched files of all 411 open apache/lucene PRs; commit history of both files on main, branch_10x and branch_10_6; local historyonly #16422 touches a hunspell file (Dictionary.java); not fixed

History

Introduced by LUCENE-9775 (902cb93db22, 2021-02-17), a one-line FORCEUCASE change to the CHECKCOMPOUNDPATTERN code added two weeks earlier; first released in 8.9.0, so no release had the feature without the bug. 11 releases from 8.9.0 to 10.5.2 and main fail; not fixed upstream.

Versions tested11 rows

Introduced by 902cb93db2274a0a1d424fb69fdd6be645887da5 "LUCENE-9775: Hunspell: make FORCEUCASE work when the first compound word is inherently title-case (#2375)" (@donnerpeter, 2021-02-17; lucene-solr#2375; issue #10814). It changed the expandReplacement expression from word.subSequence(0, breakPos) to new String(word.chars, 0, word.offset + breakPos) and left the offset at 0. The feature itself was d0ae2bd2b9c (LUCENE-9717, lucene-solr#2280, 2021-02-03, #10756), correct at that commit. Both reached 8.x in the squashed backport c6b964a11576 (lucene-solr#2499, LUCENE-9687, 2021-05-28), so 8.9.0 was the first release with the feature and with the bug. javap shows the same construction in every cached analysis-common jar from 8.9.0 to 10.5.1, and all 21 9.x and 12 10.x release tags contain 902cb93. No later commit changes the line, and no open PR touches the two files (2026-10-26).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedgradle: the primary test fails 2 of 3; the five repro classes plus TestSpellChecking with 3 fixture lines 16 of 72; assertions on and off
main ea57360912ed affectedfinder, independent reproducer, cross-model stage, devil's advocate, adjudication, audit and follow-up re-runs: fail with -ea and -da
10.5.2, 9.12.3 affectedrelease jars: the finder's JUnit 2 of 3 and the independent reproducer's 6 of 7 fail; the German example fails on 9.12.3
9.9.0 affectedrelease jars: the primary test fails 2 of 3 (it does not compile on 9.8.0, which has no SortingStrategy)
10.3.2, 10.0.0, 9.8.0, 9.4.2, 9.0.0 affectedmain() probe: barfozar and barfur false, StackOverflowError from spell and suggest("auu")
8.11.4, 8.10.1, 8.9.0 affectedmain() probe: the same (8.9.0 and 8.11.4 also the portable repro)
8.8.2 no APIthe probe does not compile: no spell-checking class
d0ae2bd2b9c (LUCENE-9717) and 2555418 bisectbarfozar and barfur true, spell("auu") false, suggest returns (shadowed over the 9.0.0 jars)
902cb93db22 (LUCENE-9775) bisectbarfozar and barfur false, StackOverflowError from spell and suggest("auu")
902cb93db22 with the line reverted bisectcorrect again: the one expression is the cause
fix A on a9779ef3c193 not affectedhunspell test package: 177 tests, 0 failures (164 pass, 13 skipped), assertions on and off; lint passes

Independent checks

An independent reproducer, cross-model reviews (a third produced no verdict), a works-as-designed devil's advocate, an adjudication and a credibility audit. Every re-run on main failed with assertions on and off, every run of the repros with the fix passed, and native Hunspell 1.3.2 and 1.7.2 gave the expected verdicts.

Checks and verdicts458 words

Each check ran or read on its own; the registration check re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193 and traced the lookups.

Each independent check and its verdict
CheckVerdictWhat it found
finder (differential testing) candidate11 overflowing words on native's Telugu dictionary; JUnit fails 2 of 3 on main, 10.5.2 and 9.12.3; the portable probe fails on 8.9.0, 8.11.4 and 9.0.0; the fix passes.
cross-model review, works-as-designed lens real, mediumAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: root cause agreed, intended argument weak; a separate re-run failed 2 of 3.
cross-model review, refute lens no verdictIt returned no verdict.
independent reproducer realOwn 7-test JUnit in another package fails 6 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; own one-line fix passes 7 of 7 and TestSpellChecking 53 of 53; native Hunspell 1.3.2 and 1.7.2 give the expected verdicts.
cross-model stage, root-cause lens real, mediumRoot cause agreed, inputs in contract, intended argument weak; a separate run re-ran the repro on main and 10.5.2 and the 146 hunspell tests, stock and fixed.
intended-behaviour devil's advocate realIntended argument weak; added the ChangeLog's German example and native's unmodified Telugu dictionary, both overflow; COMPOUNDWORDMAX 3 stops the overflow but not the false negatives.
adjudication confirmed, highRe-ran the independent JUnit (-ea, -da: 6 of 7 fail; with the fix 7 of 7 pass) and re-checked native Hunspell 1.3.2.
security screen none (strict re-evaluation 2026-10-27)Only spell and suggest reach it; stemming output unchanged; a request-level Error, and no lasting state change seen in a five-word check after the overflows.
credibility audit keepOwn probe on native's unmodified Telugu dictionary: three-part words overflow on main and 10.5.2, the fixture's own words pass; the German example fails on main -da and passes with the fix.
this doc's gradle runs and lookup trace confirmedStock main: the primary test fails 2 of 3, the five repro classes plus the fixture lines 16 of 72, assertions on and off. With the fix: 177 tests, 0 failures, 13 skipped, both modes; lint passes. The trace shows dampfs read instead of schiff.
independent fact and diagram checks claim holds; errors fixedAn independent fact check (re-ran the repro, the fix and native Hunspell), a cross-model fact check by a reviewer from another model family (OpenAI GPT-5.6) and a diagram check all found the bug real. Their text errors (an assertions-mode label, the native position rule, the 9.9.0 release floor, passed vs skipped counts, fix A's termination claim and evidence tags) are fixed here and in the diagrams; a resolution probe added the mixed-join and x yx x cases.

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

Finding record

LFV-377, snapshot of 2026-11-06

Registry title
Hunspell spell-checker: a CHECKCOMPOUNDPATTERN simplified compound form (the optional replacement field) after the first compound part is checked from the start of the whole word, so valid compounds such as dampfschiffahrt (dampf + schiff|fahrt with 'ff f ff') are rejected and some words (schiffschiffahrt, or auu with 'u u u') make spell()/suggest() throw StackOverflowError (expandReplacement returns offset 0 over an array that still holds the earlier parts)
Area
analysis / hunspell (Hunspell.spell and suggest compound checking, CHECKCOMPOUNDPATTERN replacements)
Priority
Low, rank 463: Hunspell rejects valid simplified compounds and can throw StackOverflowError, but no surveyed public dictionary uses the CHECKCOMPOUNDPATTERN replacement field.
Impact class
silent wrong results
Impact note
Only applications that call the opt-in Hunspell spell-checker (Hunspell.spell and Hunspell.suggest, and the Suggester that suggest uses) with a dictionary that uses the optional replacement field of CHECKCOMPOUNDPATTERN (simplified compound forms; none of the 191 public .aff files the finder surveyed uses it) are hit: valid compounds of 3 or more parts whose simplified boundary is not the first are reported as misspelled with no error, and words in which the replacement reappears in its own expansion make the call throw StackOverflowError (a 3-letter word with 'CHECKCOMPOUNDPATTERN u u u', words of native Hunspell's unmodified checkcompoundpattern4 fixture, schiffschiffahrt with the ChangeLog's German 'ff f ff' example); stemming, indexing and search (HunspellStemFilter, getRoots) are not affected [exec], no scale is needed, and every release from 8.9.0 to 10.6.0 has it (10.6.0 by reading).
Summary
You load a Hunspell dictionary whose CHECKCOMPOUNDPATTERN rule has a replacement, which hunspell(5) documents as 'The optional replacement parameter allows simplified compound form' (for example 'CHECKCOMPOUNDPATTERN ff f ff' from the Hunspell ChangeLog, for pre-reform German Schiffahrt = Schiff + Fahrt), and expect Hunspell.spell to accept the simplified form wherever it occurs in a compound, as native Hunspell 1.3.2 and 1.7.2 do for dampfschiffahrt, donaudampfschiffahrt, barfozar and barfur, and to return true or false for every word. Instead Lucene accepts schiffahrt but rejects dampfschiffahrt and donaudampfschiffahrt (and barfozar, barfur and barbozan on the content of its own checkcompoundpattern2/3 fixtures), and spell()/suggest() throw StackOverflowError for schiffschiffahrt, for 'auu' with the one-word dictionary au and 'CHECKCOMPOUNDPATTERN u u u', and for words of native Hunspell's own checkcompoundpattern4 (Telugu sandhi) fixture. The cause: CheckCompoundPattern.expandReplacement builds the expanded word from the start of the whole word (word.chars[0 .. word.offset + breakPos], which keeps the earlier compound parts for the FORCEUCASE check) but returns it as a CharsRef with offset 0, while its callers index it relative to the current part, so at the second and later boundaries the stem lookup starts at the beginning of the word and the remaining tail never gets shorter.
Introduced
902cb93db2274a0a1d424fb69fdd6be645887da5, LUCENE-9775 'Hunspell: make FORCEUCASE work when the first compound word is inherently title-case' (apache/lucene-solr#2375, by donnerpeter; migrated issue apache/lucene#10814), on lucene-solr master, now part of the apache/lucene main history [read: git show in the history clone changes the one expandReplacement line from word.subSequence(0, breakPos) (relative to word.offset) to new String(word.chars, 0, word.offset + breakPos) and keeps new CharsRef(String), offset 0; git blame at the history head attributes the line to this commit, and later commits to the file (97464ebfcb9 #13049, 942065cc3e7 #13328) do not touch it] [exec: with the hunspell package of each commit shadowed over the 9.0.0 jars, the feature commit d0ae2bd2b9c (LUCENE-9717, lucene-solr#2280, 2021-02-03) and the parent 2555418 are correct, 902cb93 shows the bug, and 902cb93 with only that line reverted is correct]. Reached 8.x through the squashed branch_8x backport c6b964a11576 (lucene-solr#2499, LUCENE-9687; commit id from the ASF git bot comment on LUCENE-9687, not in the local clone; the bad line is in the 8.9.0 bytecode). A regression against the 2021-02-03 feature commit, but no release ever had the feature without the bug (2021-02-17 (main); 2021-05-28 (branch_8x backport))
First affected version
8.9.0 [exec] (the first release with the spell-checking API and CHECKCOMPOUNDPATTERN; 8.8.2 has no Hunspell class and does not compile the repro [exec]); on the main line 9.0.0 [exec]
Affected versions
every release that has the feature: 8.9.0 - 8.11.4, 9.0.0 - 9.12.3 and 10.0.0 - 10.6.0 (10.6.0 by reading), and main ea57360912ed. Executed (history probe or version-portable main(), -ea): 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.4.2, 9.8.0, 9.12.3, 10.0.0, 10.3.2, 10.5.2 and main; the finder's and the independent reproduction's JUnits fail on 9.12.3, 10.5.2 and main (main also -da), the intended-behaviour check's German example on 9.12.3 and main (-ea and -da) [exec]. [read] javap shows the same new String(chars, 0, offset + breakPos) construction in expandReplacement in every cached analysis-common jar from 8.9.0 to 10.5.1, and all 21 9.x and 12 10.x release tags contain 902cb93. Not affected: 8.8.2 and older (no spell-checking API) [exec]. Not fixed upstream: no commit after 902cb93 changes expandReplacement, and no open apache/lucene PR touches CheckCompoundPattern.java or Hunspell.java (2026-10-26). 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; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record verdict new, 159 query records: two scripted prior-report searches over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 94 read-only GitHub GraphQL searches, including a listing of all 218 apache/lucene items that mention hunspell; 43 items read in full; the touched files of all 411 open apache/lucene PRs; local git history; 17 hits judged, none the same bug; the finder's and the intended-behaviour check's searches agree). Closest items: #10814 (LUCENE-9775) and lucene-solr#2375, the change that introduced the bug (no discussion of the offset or of compounds of 3 or more parts; the author's review note says the earlier parts are kept for the case check); #10756 (LUCENE-9717, lucene-solr#2280), the feature, whose ported fixtures have only 2-part simplified forms; #10768 (LUCENE-9729), an earlier offset/length fix in the same class (precedent, not this bug); native hunspell/hunspell#126 (2010), an analogous multi-boundary CHECKCOMPOUNDPATTERN bug in the C++ code (prohibition patterns, not the replacement); #16422 (the only open PR touching a hunspell file, Dictionary.java only). Not fixed upstream.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, stock and with the fix; the module's lint check) and main ea57360912ed (hunt, independent checks, adjudication, audit, follow-up re-runs and a lookup-trace probe; no file of the analysis-common module differs between the two commits); the release jars 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.4.2, 9.8.0, 9.9.0, 9.12.3, 10.0.0, 10.3.2 and 10.5.2 (8.8.2 has no spell-checking API; the releases in between by bytecode and release tags); the feature commit d0ae2bd2b9c, the introducing commit 902cb93db22, its parent and the commit with the line reverted, shadowed over the 9.0.0 jars; native Hunspell 1.3.2 and 1.7.2 as the reference. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/CheckCompoundPattern.java at ea57360912ed: :94-103 expandReplacement (:97 new String(word.chars, 0, word.offset + breakPos), wrapped in new CharsRef(String), offset 0)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java at ea57360912ed: :34-47 class javadoc (replacement for native Hunspell 'for many languages'; unsupported: Hungarian, non-BMP, PHONE), :71-74 (spell javadoc 'whether the given word's spelling is considered correct according to Hunspell rules'), :213-247 checkCompounds (:214 the compoundMax bound, :215-218 the FORCEUCASE offset-0 guard), :249-267 checkCompoundPatternReplacements (:257 findStem(expanded.chars, expanded.offset, breakPos, ...)), :269-286 checkCompoundsAfter (:284 recursion on new CharsRef(word.chars, breakOffset, remainingLength)), :574-597 suggest (default 250 ms limit, RETURN_PARTIAL_RESULT from the default constructor :55-57)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:152 (compoundMin = 3, compoundMax = Integer.MAX_VALUE)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundpattern2.aff, .dic, .good (fozar, fur, barfoo), checkcompoundpattern3.* (all simplified forms 2-part); TestHunspellRepositoryTestCases.java:38 EXPECTED_FAILURES (checkcompoundpattern4 not excluded)
The finding in 30 seconds
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/CheckCompoundPattern.java:94-103
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:100, 150-161, 213-247, 249-267, 269-286
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:152, 439
Who is affected
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Suggester.java:184-185, 215, 298, 301; HunspellStemFilter.java:86, 111; Hunspell.java:165, 300, 574-597
Why it looks unintended
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-46, 71-74
  • a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestSpellChecking.java:97-106; TestHunspellRepositoryTestCases.java:38-46, 57-59; checkcompoundpattern2.good, checkcompoundpattern3.good
Suggested fix
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:279, 288-292; a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/CharsRef.java:60-64

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

Repro files

About the diagrams

A picture of the worked example, made in parallel with this doc; the alt text describes the example.

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.