All findings · Lucene finding · LFV-373 · hunspell-checkcompoundtriple-later-boundary-absolute-offset

Hunspell spell-checker can miss an x|xx triple letter at the second and later joins of a compound

With a dictionary that forbids three identical letters across a compound join (CHECKCOMPOUNDTRIPLE), Hunspell.spell rejects bareeel (bare|eel) but accepts operabareeel (opera + bare|eel): after the first join, half of the check compares a position in the whole word with the length of the rest. Affects 8.9.0 to 10.6.0 (10.6.0 by reading).

Summary

Priority
Low, rank 457The opt-in Hunspell spell-checker silently accepts some misspelled compounds of three or more parts in a real public dictionary (sv_SE), a rare spelling-verdict error like DirectSpellChecker's LFV-240, so it heads the new Hunspell rows after it.
Impact
silent wrong results
Since
8.9.0 (LUCENE-9710, 2021)introduced 2021-02-01 · about 5 years
Affected
8.9.0 to 10.6.0 (10.6.0 by reading) and main; 13 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 each join. For the second and later joins, the "one letter before, two after" half of the triple check compares the join's position in the whole word with the length of only the part being split, so it is skipped once enough letters come before that part.

The word operabareeel split into opera, bare and eel. At the second join, bare|eel at position 9 of the whole word, the code tests 9 < 7 - 1 (the length of bareeel minus 1), which is false, so the triple eee is not checked and the word is accepted; with the fixed bound 5 + 7 - 1 = 11 it is rejected.
The repro's example: operabareeel split as opera + bare + eel, and the triple-letter check at its second join. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Load Lucene's own checkcompoundtriple test dictionary: CHECKCOMPOUNDTRIPLE, COMPOUNDFLAG A and four words, foo, opera, eel, bare. spell("bareeel") returns false: bare|eel puts eee across the join, and Lucene's fixture lists it as wrong.
  2. Put another part in front: spell("operabareeel"), opera + bare|eel. You expect false, as for bareeel; C Hunspell 1.3.2 and 1.7.2 return false.
  3. You get true, with no error. foobareeel, barebareeel and eelbareeel are accepted too, while the mirror form operafooopera (opera + foo|opera) is still rejected.
  4. Why: at the second join the part being split is bareeel (it starts at position 5 and has 7 letters), and the join is at position 9 of the whole word. The "one letter before, two after" test checks 9 < 7 - 1, which is false, so it is skipped; the right bound is 5 + 7 - 1 = 11.
  5. Real dictionary: Swedish (sv_SE) rejects musikradiooomskuren but accepts sommarmusikradiooomskuren, the same triple o with one more part in front. Only spell-check verdicts and suggestions change; stemming, indexing and search do not.

Who is affected

Only applications that call the opt-in spell-checker (Hunspell.spell; the suggesters use the same check) with a dictionary that sets CHECKCOMPOUNDTRIPLE and allows compounds of three or more parts, such as Swedish sv_SE. Some misspelled compounds are reported as correct. Stemming (HunspellStemFilter), indexing and search are unaffected (by the code path; a stem-filter probe agrees). Every release from 8.9.0 to 10.6.0 (10.6.0 by reading).

Mechanism, terms and numbers642 words

CHECKCOMPOUNDTRIPLE tells Hunspell to reject a compound when a join puts three identical letters in a row, either two before and one after the join (foo|ox) or one before and two after (xo|oof). Lucene's Hunspell.spell splits a compound left to right and checks each join. From the second join on, the code passes the join as a position in the whole word but the length of only the part still being split, and the "one before, two after" half of the test compares the two (breakPos < length - 1, Hunspell.java:430), while the other half adds the part's offset. Once enough letters precede the current part, that half is skipped. With Lucene's own test dictionary, bareeel is rejected but operabareeel, foobareeel and barebareeel are accepted; with the public Swedish dictionary, sommarmusikradiooomskuren is accepted. C Hunspell rejects all of them. Only the opt-in spell-checker (spelling verdicts and suggestions) is affected, in every release from 8.9.0 to 10.6.0 (10.6.0 by reading); the fix is one line.

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 tries a first part, then splits the rest the same way, recursively, so a word can have several parts (COMPOUNDWORDMAX, the part cap, defaults to unlimited, Dictionary.java:152; the compound flags or rules decide which words may start, continue or end a compound).
  • CHECKCOMPOUNDTRIPLE (hunspell(5)): "Forbid compounding, if compound word contains triple repeating letters (e.g. foo|ox or xo|oof)." Two shapes: xx|x, two equal letters before the join and one after (foo|opera), and x|xx, one before and two after (bare|eel).
  • The part being split: from the second join 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 (how many letters it has).

The bug

  1. checkCompounds computes the join as a position in the whole word, breakOffset = word.offset + breakPos (Hunspell.java:223), and calls mayBreakIntoCompounds(word.chars, word.offset, word.length, breakOffset) (:224): offset and length of the slice, position in the whole word.
  2. After a part is found, checkCompoundsAfter recurses on the rest, new CharsRef(word.chars, breakOffset, remainingLength) (:284), so from the second join on the offset is greater than 0.
  3. mayBreakIntoCompounds (:427-433) tests breakPos > offset + 1 && chars[breakPos - 2] == chars[breakPos - 1] for xx|x (offset-aware) || breakPos < length - 1 && chars[breakPos] == chars[breakPos + 1] for x|xx (:430): a position in the whole word compared with the slice's own length.
  4. Whenever the join's position is at least the slice length minus 1, the x|xx test is skipped and the join is allowed. The buggy bound is stricter than the right one (offset + length - 1), so it never reads out of bounds; it only skips the check.

The numbers

With p letters before the part being split and a letters after the join, the x|xx test runs only when p < a - 1. At the first join p = 0, so it always runs.

wordpartspatest runs?Lucene mainC Hunspell
bareeelbare|eel03yesrejectedrejected
foobareeelgrassfoo + bare|eelgrass38yesrejectedrejected
operabareeelopera + bare|eel53noacceptedrejected
operafoobareeelgrassopera + foo + bare|eelgrass88noacceptedrejected
sunradiooolongsun + radio|oolong36yesrejectedrejected
sunsunradiooolongsun + sun + radio|oolong66noacceptedrejected

The same bare|eel join is rejected in foobareeelgrass and accepted in operafoobareeelgrass: only the prefix differs. C Hunspell recurses on the substring (affixmgr.cxx:2084), so both halves of its test use positions inside the rest (affixmgr.cxx:1823-1827).

Exposure and likelihood350 words
  • Who. Callers of the spell-checker API: Hunspell.spell, and the suggesters, which validate candidates with the same check (Suggester.java:215, :298; ModifyingSuggester.java:362-396; GeneratingSuggester.java:323). A probe on main with the test dictionary: suggestNoTimeout("operabareeell") returns [operabareeel], the misspelled word, and [] with the fix.
  • Opt-in twice. The spell-checker is a separate API that no analyzer calls, and the dictionary must set CHECKCOMPOUNDTRIPLE and allow compounds of 3 or more parts (COMPOUNDWORDMAX sets no cap by default; whether a middle part is allowed depends on the dictionary's compound flags or rules).
  • Real dictionaries. Among 191 public .aff files surveyed (local copies of the LibreOffice dictionaries and wooorm/dictionaries), CHECKCOMPOUNDTRIPLE appears in hu_HU, nb_NO and sv_SE/sv_FI. Per the finder's survey, hu_HU allows only 2-part compounds and nb_NO has no compoundable root that starts with a doubled letter. In sv_SE the finder's targeted search found 20 accepted triple words, all an oo- word (oomskuren, oomstridd, oomkullkastlig, oomkullrunkelig) after a part ending in o, such as sommarmusikradiooomskuren and sommarpolisradiooomskuren; the independent reproducer re-ran the first, the devil's advocate both. sv_FI and nb_NO were not run.
  • Effect. A misspelled compound is reported as correct, silently. Correct words are not affected: the bug only skips a rejection. In sv_SE the simplified spelling sommarmusikradioomskuren is rejected by both C Hunspell and Lucene, so only the triple form is newly accepted.
  • Not affected. HunspellStemFilter uses Stemmer (HunspellStemFilter.java:86, :110-111) and getRoots returns no roots for compounds (Hunspell.java:300); executed: getRoots("operabareeel") is [] and the filter emits the same tokens with and without the fix. No index or query test was run; the rest follows from the code path. A code search of Elasticsearch, OpenSearch and Solr (which can miss hits) found only the stem filter.
  • Likelihood: rare. It needs such a dictionary, a compound of 3 or more parts, a later part that starts with a doubled letter equal to the previous part's last letter, and enough letters before it (p >= a - 1). No scale is needed: one word triggers it.

Minimal repro

The test shows that Hunspell's CHECKCOMPOUNDTRIPLE misses an x|xx triple letter at the second boundary of a compound: with opera/A bare/A eel/A, operabareeel (opera + bare|eel) is accepted.

TestCheckCompoundTripleLaterBoundary.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 TestCheckCompoundTripleLaterBoundary extends LuceneTestCase {

  public void testTripleLetterAtSecondBoundary() throws Exception {
    byte[] aff = "CHECKCOMPOUNDTRIPLE\nCOMPOUNDFLAG A\n".getBytes(StandardCharsets.UTF_8);
    byte[] dic = "3\nopera/A\nbare/A\neel/A\n".getBytes(StandardCharsets.UTF_8);
    Dictionary dictionary =
        new Dictionary(
            new ByteArrayInputStream(aff),
            List.of(new ByteArrayInputStream(dic)),
            false,
            SortingStrategy.inMemory());
    // expected: rejected, as bareeel is (bare|eel has "eee"); actual: accepted
    assertEquals("spell(\"operabareeel\")", false, new Hunspell(dictionary).spell("operabareeel"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-373/, 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.TestCheckCompoundTripleLaterBoundary
Expected vs actual
Expected spell("operabareeel") to be false (bare|eel has "eee", and Lucene's own fixture rejects bareeel); actual: true (expected:<false> but was:<true>), with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: spell("operabareeel") expected:<false> but was:<true>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.analysis.hunspell.TestCheckCompoundTripleLaterBoundary.testTripleLetterAtSecondBoundary(TestCheckCompoundTripleLaterBoundary.java:36)

Why it looks unintended

  • hunspell(5): "Forbid compounding, if compound word contains triple repeating letters (e.g. foo|ox or xo|oof)", with no part limit; the skipped half is exactly xo|oof.
  • The other half of the same test adds the offset (breakPos > offset + 1); this half does not (breakPos < length - 1).
  • C Hunspell 1.3.2 and 1.7.2 reject every affected word tried.
The full argument269 words
  • Contract. hunspell(5): "CHECKCOMPOUNDTRIPLE Forbid compounding, if compound word contains triple repeating letters (e.g. foo|ox or xo|oof)." It sets no limit on the number of parts, and "COMPOUNDWORDMAX ... (Default is unlimited.)". Hunspell.spell returns "whether the given word's spelling is considered correct according to Hunspell rules" (Hunspell.java:71-74). The class "can be used in place of native Hunspell for many languages" (:35-36) and notes that "not all languages are supported yet. For example:" Hungarian, non-BMP characters, the PHONE option (:36-44). Those are examples, not a full list, so they neither include nor exclude this case; nothing in the javadoc, tests or history marks multi-part CHECKCOMPOUNDTRIPLE as unsupported.
  • Tests. Lucene's fixture checkcompoundtriple.wrong lists fooopera and bareeel, run by TestSpellChecking.testCheckCompoundTriple (TestSpellChecking.java:132-134). The fixture, the same as C Hunspell's, has only 2-part compounds, so it never reaches a later join. No test asserts that a later-join triple is accepted.
  • Code. Two halves of one boolean expression use different index spaces, and the verdict for the same join flips with the prefix length. C Hunspell, which the code ports, uses positions inside the rest in both halves; Lucene's breakPos > offset + 1 is the port of its i > 1.
  • History. Both halves were written in one commit (9d45dfe776a2, LUCENE-9710, 2021-02-01); its PR had no review comments, and the issue has no design discussion. CHANGES 8.9.0 (LUCENE-9687) says the work fixed "various behavior differences between Java and C++ implementations" (lucene/CHANGES.txt:5368-5369).
  • Reference. C Hunspell 1.3.2 and 1.7.2 reject all 9 triple words of the independent reproducer's set on the same dictionary.
The devil's-advocate check155 words

The strongest case: the class promises to replace native Hunspell only for many languages, and both the manual's examples and the fixtures show only 2-part compounds, so behaviour for 3 or more parts is unspecified.

Why it fails: the manual states the rule for any compound and sets no part limit; COMPOUNDWORDMAX defaults to unlimited; Lucene enforces the other half (xx|x) at every join, so it treats the rule as applying everywhere; the javadoc's examples of unsupported cases do not name this directive or Swedish (they are only examples, so this point is weak on its own); and nothing explains why the same join is accepted or rejected depending only on how many letters precede it. The fix does change established spell and suggest results, a compatibility cost noted under the fix, not a sign that the current results are intended. Every reviewer rated the intended-behaviour case weak. Verdict: not intended, a defect.

Suggested fix

Recommended A: add the part's offset to the bound (one line)

Add the part's offset to the bound, as the other half of the test already does: breakPos < offset + length - 1 (one line, Hunspell.java:430). With it the repros pass, the hunspell test package runs 165 tests with 0 failures and 13 skipped (they need external dictionaries), with assertions on and off, and the module's lint check passes.

if (breakPos > offset + 1 && chars[breakPos - 2] == chars[breakPos - 1]
-    || breakPos < length - 1 && chars[breakPos] == chars[breakPos + 1]) {
+    || breakPos < offset + length - 1 && chars[breakPos] == chars[breakPos + 1]) {
  return false;
}
All options and trade-offs2 options · 511 words

One fix was built and run in a scratch worktree at a9779ef3c193; it matches C Hunspell's test and the other half of Lucene's. For the regression test, the 3 fixture lines are the smallest change (the fixture then has words C Hunspell's copy lacks, which C also rejects); a dedicated test class like the primary repro states the intent more clearly. Test scope: the runs cover flag-based recursive compounding (COMPOUNDFLAG, COMPOUNDBEGIN/MIDDLE/END); combinations with CHECKCOMPOUNDPATTERN, COMPOUNDRULE or SIMPLIFIEDTRIPLE were not tested for exact parity with C Hunspell (whose triple test also has scpd == 0 && !words guards, affixmgr.cxx:1823-1827, and a second copy at :2418-2422).

A: add the part's offset to the bound (one line) (validated by the checks) (recommended)

Compare the absolute position with the part's end, offset + length, as the xx|x half does with its start. chars[breakPos + 1] stays inside the part.

if (breakPos > offset + 1 && chars[breakPos - 2] == chars[breakPos - 1]
-    || breakPos < length - 1 && chars[breakPos] == chars[breakPos + 1]) {
+    || breakPos < offset + length - 1 && chars[breakPos] == chars[breakPos + 1]) {
  return false;
}

Trade-offs

Changes verdicts only for CHECKCOMPOUNDTRIPLE dictionaries, and only to reject words with an x|xx triple at a later join, which C Hunspell rejects too. In the sv_SE words measured, rejected words do the same or fewer lookups than on main (436 vs 465 and 1,371 vs 1,420), and words main wrongly accepts now run the full search before they are rejected (1,350 vs 290 lookups); all took about 4 ms or less per word. Not benchmarked further. Compatibility: it deliberately changes what the public spell() and the suggesters return for such words (operabareeell no longer gets the suggestion operabareeel); applications or tests that rely on the old acceptance would see that, so it is a narrow behaviour fix to note in CHANGES.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: stock plus the fixture lines fails 4 of 60 (the two repro classes and TestSpellChecking), with assertions on and off.
  • With the fix and the fixture lines: --tests 'org.apache.lucene.analysis.hunspell.*' plus the two repro classes, 48 classes, 165 tests, 0 failures, 13 skipped (tests that need external dictionaries), with assertions on and off.
  • :lucene:analysis:common:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the fix and the fixture lines.
  • Earlier, with the fix shadowed on ea57360912ed: all repro runs pass (finder 2/2, independent reproducer 5/5, cross-model stage 4/4, judge re-run 5/5), Lucene's hunspell test classes 146/146, and the introducing commit plus the fix rejects every triple.

B: pass positions relative to the part, as C Hunspell does

Give mayBreakIntoCompounds the break inside the part and index chars[offset + i], so both halves read i > 1 and i < length - 1.

Trade-offs

Intended to be equivalent to A by inspection, with a larger diff (the caller and the CHECKCOMPOUNDCASE lines change too). No patch was written and nothing was run.

Downstream note

Elasticsearch, OpenSearch and Solr use Hunspell only through HunspellStemFilter (stemming), per a code search of their default branches (a code search can miss hits), so their indexing and search are not affected; only applications that call the spell-checker API itself are.

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, including sampled three-part concatenations. Four three-part words of the checkcompoundtriple fixture were accepted only by Lucene; code reading found the cause, and a search of the Swedish dictionary found real accepted words.

The method in full140 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, all 2-part and sampled 3-part concatenations of roots and forms. A small C driver called Hunspell_spell per word, and the results were compared with the stock Lucene jar.
  • For the checkcompoundtriple fixture, 4 three-part words were accepted only by Lucene: operabareeel, foobareeel, eelbareeel, barebareeel.
  • Code reading of mayBreakIntoCompounds found the mixed index spaces; the finder reduced it to a public-API JUnit and searched the public sv_SE dictionary for accepted triple words (20 found).
  • An independent reproducer, a cross-model stage with its own executed test, a devil's advocate and an adjudication then confirmed it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (139 query records over Lucene issues, PRs, commits, JIRA, the mailing lists, downstream trackers and the web, 2026-10-26). The only item naming CHECKCOMPOUNDTRIPLE is LUCENE-9710 (#10749), which added the check with this expression; no open PR touches Hunspell.java.

Searches4 searches

Registry verdict new: no report, issue, PR or discussion of this bug by anyone else was found. Related, not reports: #10749 / lucene-solr#2272 (introduced the check; no discussion of the bound or of 3+ parts), #10746 (LUCENE-9707, the C Hunspell conformance tests, whose triple fixtures have only 2-part words), #10811 (LUCENE-9772, changed the CHECKCOMPOUNDCASE clause of the same method). Gaps: keyword-based searches, one throttled web query, private channels not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA; downstream; web2026-10-26a scripted prior-report search, 51 queriesonly LUCENE-9710 and its commit
GitHub (apache/lucene, apache/lucene-solr, downstream)2026-10-2648 read-only GraphQL searches; all 218 + 94 items matching "hunspell" listed, the 19 compound-related or open ones readno report
JIRA (LUCENE, SOLR), lists.apache.org, web2026-10-266 JIRA REST, 16 list and 8 web searchesno report
Open PRs and upstream code2026-10-26touched files of all 411 open apache/lucene PRs; Hunspell.java on main, branch_10x, branch_10_6; local historyonly #16422 touches a hunspell file (Dictionary.java); line 430 unchanged; not fixed

History

Written this way when CHECKCOMPOUNDTRIPLE support was added (9d45dfe776a2, LUCENE-9710, 2021-02-01), first released in 8.9.0; not a regression. 13 releases from 8.9.0 to 10.5.2 (one of them in the fact check) and main fail; the line is unchanged on upstream main, branch_10x and branch_10_6 (2026-10-26).

Versions tested11 rows

Introduced by 9d45dfe776a29b50143a0eedce1ccda47c8bbac2 "LUCENE-9710: Hunspell: support minor compounding-related flags (#2272)" (@donnerpeter, 2021-02-01; lucene-solr#2272, merged by @dweiss with no review comments; issue #10749, a sub-task of LUCENE-9687, #10726). The class was then named SpellChecker (renamed Hunspell by LUCENE-9765). The commit wrote both halves of the test as they are today, while the caller already recursed with an absolute offset and a relative length. It reached 8.x in the squashed backport c6b964a11576 (lucene-solr#2499, 2021-05-28). Before it the directive was not implemented at all (with a COMPOUNDBEGIN/MIDDLE/END dictionary every triple was accepted; COMPOUNDFLAG, added by the same commit, did not work yet); no release shipped that state. Later commits touched only the CHECKCOMPOUNDCASE lines of the method; the clause is byte-identical at all 33 release tags from 9.0.0 to 10.5.2.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedgradle: the primary test fails 1 of 2, the three repro files 4 of 60, assertions on and off
main ea57360912ed affectedfinder, independent reproducer, cross-model stage, devil's advocate, judge and follow-up re-runs: fail with -ea and -da
10.5.2, 9.12.3 affectedthe JUnit fails 1 of 2 with -ea and -da (release jars)
9.9.0 fact checkthe JUnit fails 1 of 2 (-ea, release jars); 9.9.0 is the first release with SortingStrategy, so on 9.8.0 the JUnit does not compile
10.0.0, 9.0.0 affectedmain() probe: operabareeel and barebareeel accepted
8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4 affectedmain() probe: the same (8.9.0 also with a COMPOUNDBEGIN/MIDDLE/END dictionary and with -da)
8.8.2 no APIthe probe does not compile: no spell-checking class
parent 40e92315aeaf not implementedCHECKCOMPOUNDTRIPLE ignored: with a COMPOUNDBEGIN/MIDDLE/END dictionary every triple accepted; COMPOUNDFLAG not supported yet, so the fixture-style run is vacuous (shadowed over the 9.0.0 jars)
9d45dfe776a2 (LUCENE-9710) bisectfirst join and later xx|x checked; operabareeel and barebareeel accepted
9d45dfe776a2 + the fix not affectedevery triple rejected, controls accepted
fix A on a9779ef3c193 not affectedhunspell test package: 165 tests, 0 failures, 13 skipped (they need external dictionaries), assertions on and off; lint passes

Independent checks

An independent reproducer, cross-model reviews, 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 with the fix passed. A cross-model review could not run code and voted not real on procedure only while agreeing on the root cause.

Checks and verdicts475 words

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

Each independent check and its verdict
CheckVerdictWhat it found
finder (differential testing) candidate4 three-part fixture words accepted only by Lucene; JUnit fails on main, 10.5.2 and 9.12.3, probe on 8.9.0 and 9.0.0; 20 accepted sv_SE words.
cross-model review, refute lens not real, low (procedural)An independent reviewer from another model family (OpenAI GPT-5.6) could not run code; it voted not real on procedure only. Its reading agreed with the root cause and found the inputs in contract; a separate re-run failed 1 of 2.
independent reproducer realOwn 5-test JUnit fails 2 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; controls pass; the fix passes 5 of 5; C Hunspell 1.3.2 and 1.7.2 reject all 9 triple words; sv_SE example.
cross-model review, works-as-designed lens real, mediumIntended-behaviour argument rated weak; a separate test with other words (sun, radio, oolong) predicted which joins are skipped and failed on main and 10.5.2 exactly there; the fix passes 4 of 4.
intended-behaviour devil's advocate realNo test, CHANGES entry, javadoc or discussion supports the current behaviour; repro fails on main and 10.5.2; C Hunspell 1.7.2 rejects the words; sv_SE confirmed.
adjudication confirmed, highRe-ran the independent JUnit (-ea, -da: 2 of 5 fail; with the fix 5 of 5 pass).
security screen security noneNo exception over 30,000 random words; with the directive, spell() never searched more than the full no-directive search (the 7,389 random words that configuration rejects; per word it can take more steps when the no-directive run accepts early); stemming output unchanged.
credibility audit keepOwn probe with other words (kira|aal) failed exactly where the trigger condition predicts; the fix passes.
this doc's gradle runs confirmedStock main: the primary test fails 1 of 2, the three repro files 4 of 60, assertions on and off. With the fix: 165 tests, 0 failures, 13 skipped, both modes; lint passes. A suggest probe shows the misspelled word offered as a suggestion on main only.
independent fact check 4 slips, fixedRe-ran the doc's command, the stock and fixed gradle suites, the harness repro and its own probes; found four wording slips (the SortingStrategy floor, a stem-filter input, a per-word CPU claim, skipped tests counted as passes), all fixed here. Added 9.9.0 as an executed affected release.
cross-model fact check sound; wording fixedAn independent reviewer from another model family (OpenAI GPT-5.6), reading files only: the bug, the diagnosis, the repros and fix A hold; its overclaims and missing caveats (scope, compatibility, the javadoc's examples) are fixed or answered.
diagram check 3 errors, fixedRe-ran the diagram probe and checked every frame against the code.

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

Finding record

LFV-373, snapshot of 2026-11-06

Registry title
Hunspell spell-checker: CHECKCOMPOUNDTRIPLE misses an x|xx triple letter (one letter before the boundary, two after) at the second and later boundaries of a compound, so words such as operabareeel (opera + bare|eel) are accepted (absolute break position compared with the tail's relative length)
Area
analysis / hunspell (Hunspell.spell compound checking)
Priority
Low, rank 457: The opt-in Hunspell spell-checker silently accepts some misspelled compounds of three or more parts in a real public dictionary (sv_SE), a rare spelling-verdict error like DirectSpellChecker's LFV-240, so it heads the new Hunspell rows after it.
Impact class
silent wrong results
Impact note
Only applications that call the opt-in Hunspell spell-checker (Hunspell.spell; by code reading also the suggesters, which validate candidates with the same compound check, not executed) with a dictionary that sets CHECKCOMPOUNDTRIPLE and builds compounds of 3 or more parts (sv_SE/sv_FI among the public dictionaries the finder surveyed; per that survey nb_NO has no compoundable root that starts with a doubled letter and hu_HU allows only 2-part compounds) are hit: some misspelled compounds with a triple letter at a later boundary are reported as correctly spelled, with no error; stemming, indexing and search (HunspellStemFilter) 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 with CHECKCOMPOUNDTRIPLE, which hunspell(5) defines as 'Forbid compounding, if compound word contains triple repeating letters (e.g. foo|ox or xo|oof)', and expect Hunspell.spell to reject a compound with a triple letter at any boundary, as C Hunspell 1.3.2 and 1.7.2 do and as Lucene's own checkcompoundtriple fixture requires for bareeel (bare|eel). Instead Lucene rejects bareeel but accepts operabareeel, barebareeel and foobareeel (opera, bare or foo + bare|eel), and with the public Swedish dictionary (sv_SE) it accepts sommarmusikradiooomskuren while it rejects musikradiooomskuren. The cause: Hunspell.mayBreakIntoCompounds receives an absolute break position (the tail's offset in the word plus the relative break) but tests 'breakPos < length - 1' against the tail's relative length, so at the second and later boundaries the x|xx half of the check is skipped whenever the absolute break position is at least the tail length minus 1, while the xx|x half ('breakPos > offset + 1') is offset-aware and still works.
Introduced
9d45dfe776a29b50143a0eedce1ccda47c8bbac2, LUCENE-9710 'Hunspell: support minor compounding-related flags' (apache/lucene-solr#2272, by donnerpeter, merged by dweiss with no review comments; migrated issue apache/lucene#10749; a sub-task of LUCENE-9687, #10726), then in SpellChecker.java (renamed Hunspell.java by LUCENE-9765, 02ea7a11392) [read: git blame -w -C -C -M of Hunspell.java:427-431 in the history clone attributes the clause to this commit with no later edit; git log -L shows later commits touched only the CHECKCOMPOUNDCASE lines of the method and restructured the caller, which passed an absolute break position with a relative length at every step; exec: with the hunspell package of the parent 40e92315aeaf shadowed over the 9.0.0 jars CHECKCOMPOUNDTRIPLE is not implemented at all, with that of 9d45dfe776a2 the probe shows this bug, and 9d45dfe776a2 plus the one-line fix passes]. Reached 8.x through the squashed branch_8x backport c6b964a11576 (lucene-solr#2499, LUCENE-9687). Present since the check was first written; not a regression (2021-02-01 (main); 2021-05-28 (branch_8x backport))
First affected version
8.9.0 [exec] (the first release with the spell-checking API; 8.8.2 and older have no Hunspell or SpellChecker class [exec: the probe does not compile against 8.8.2]); on the 9.x line 9.0.0 [exec]
Affected versions
every release that has the spell-checking API: 8.9.0 - 8.11.4, 9.0.0 - 10.5.2 and 10.6.0 (by reading), and main ea57360912ed. Executed (version-portable main() probe, COMPOUNDFLAG dictionary, -ea): 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the same probe with a COMPOUNDBEGIN/COMPOUNDMIDDLE/COMPOUNDEND dictionary on 8.9.0, 9.0.0, 10.5.2 and main, and with -da on 8.9.0; the JUnit fails on 9.12.3, 10.5.2 and main with -ea and -da [exec]. [read] the clause and its call are byte-identical at all 33 release tags 9.0.0 - 10.5.2 in the history clone. Not fixed upstream: Hunspell.java line 430 is unchanged on upstream main e1c4184cb983, branch_10x and branch_10_6 (2026-10-26). Not affected: 8.8.2 and older (no spell-checking API). 10.6.0 (released 2026-10-29) was read, not executed. All runs on 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 could only read the code (its low-confidence “not real” is procedural), 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, 139 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; 48 read-only GitHub GraphQL searches, including a listing of all 218 apache/lucene and 94 apache/lucene-solr items that match 'hunspell' with the 19 compound-related or open ones read in full; JIRA REST (LUCENE, SOLR); lists.apache.org; web search; the touched files of all 411 open apache/lucene PRs; local git history; none the same bug; the finder's and the intended-behaviour check's searches agree). Closest items: #10749 (LUCENE-9710) and lucene-solr#2272, the introducing issue and PR (no discussion of the bound or of compounds of 3 or more parts); #10746 (LUCENE-9707, added the C Hunspell conformance tests, whose triple fixtures have only 2-part compounds); #10811 (LUCENE-9772, changed the CHECKCOMPOUNDCASE clause of the same method, not the triple clause); #16422 (the only open PR touching a hunspell file, Dictionary.java only). Not fixed upstream (Hunspell.java:430 unchanged on upstream main e1c4184cb983, branch_10x and branch_10_6).
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, stock and with the fix; the module's lint check; the main refresh; a suggest probe) and main ea57360912ed (hunt, independent checks, adjudication, follow-up re-runs); the release jars 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and 9.9.0 in the fact check (8.8.2 has no spell-checking API); the introducing commit 9d45dfe776a2, its parent and the commit plus the fix, shadowed over the 9.0.0 jars. Java runs on OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java at ea57360912ed: :71-74 (spell javadoc 'whether the given word's spelling is considered correct according to Hunspell rules'), :213-247 checkCompounds (:223 breakOffset = word.offset + breakPos, :224 mayBreakIntoCompounds(word.chars, word.offset, word.length, breakOffset)), :269-286 checkCompoundsAfter (:284 recursion on new CharsRef(word.chars, breakOffset, remainingLength)), :300 ('No roots are returned for compound words'), :419-435 mayBreakIntoCompounds (:429 'breakPos > offset + 1', :430 'breakPos < length - 1')
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:152 (compoundMin = 3, compoundMax = Integer.MAX_VALUE), :456-457 (CHECKCOMPOUNDTRIPLE, SIMPLIFIEDTRIPLE parsing)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundtriple.aff, .dic, .good, .wrong (.wrong lists fooopera and bareeel; all fixture words have 2 parts)
The finding in 30 seconds
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:100, 150-161, 213-247, 269-286, 419-435
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:151-152, 439, 456-457
Who is affected
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Suggester.java:215, 298; ModifyingSuggester.java:362-396; GeneratingSuggester.java:323; HunspellStemFilter.java:86, 110-111; Hunspell.java:300
Why it looks unintended
  • C Hunspell 1.7.2 man/hunspell.5:461-462, 471-474
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-46, 71-74, 427-433
  • a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundtriple.wrong, checkcompoundtriple.good, TestSpellChecking.java:132-134
  • a Lucene main checkout lucene/CHANGES.txt:5368-5369

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 repro's 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.