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.
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.- Load Lucene's own
checkcompoundtripletest dictionary:CHECKCOMPOUNDTRIPLE,COMPOUNDFLAG Aand four words, foo, opera, eel, bare.spell("bareeel")returns false: bare|eel putseeeacross the join, and Lucene's fixture lists it as wrong. - Put another part in front:
spell("operabareeel"), opera + bare|eel. You expect false, as forbareeel; C Hunspell 1.3.2 and 1.7.2 return false. - You get true, with no error.
foobareeel,barebareeelandeelbareeelare accepted too, while the mirror formoperafooopera(opera + foo|opera) is still rejected. - 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 checks9 < 7 - 1, which is false, so it is skipped; the right bound is5 + 7 - 1 = 11. - Real dictionary: Swedish (sv_SE) rejects
musikradiooomskurenbut acceptssommarmusikradiooomskuren, the same triple o with one more part in front. Only spell-check verdicts and suggestions change; stemming, indexing and search do not.
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.
- Compound checking: when a word is not in the dictionary,
Hunspell.spelltries to split it into dictionary words that may be compounded (here, words with theCOMPOUNDFLAG). 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), andx|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).
checkCompoundscomputes the join as a position in the whole word,breakOffset = word.offset + breakPos(Hunspell.java:223), and callsmayBreakIntoCompounds(word.chars, word.offset, word.length, breakOffset)(:224): offset and length of the slice, position in the whole word.- After a part is found,
checkCompoundsAfterrecurses on the rest,new CharsRef(word.chars, breakOffset, remainingLength)(:284), so from the second join on the offset is greater than 0. mayBreakIntoCompounds(:427-433) testsbreakPos > offset + 1 && chars[breakPos - 2] == chars[breakPos - 1]forxx|x(offset-aware)|| breakPos < length - 1 && chars[breakPos] == chars[breakPos + 1]forx|xx(:430): a position in the whole word compared with the slice's own length.- Whenever the join's position is at least the slice length minus 1, the
x|xxtest 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.
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.
| word | parts | p | a | test runs? | Lucene main | C Hunspell |
|---|---|---|---|---|---|---|
bareeel | bare|eel | 0 | 3 | yes | rejected | rejected |
foobareeelgrass | foo + bare|eelgrass | 3 | 8 | yes | rejected | rejected |
operabareeel | opera + bare|eel | 5 | 3 | no | accepted | rejected |
operafoobareeelgrass | opera + foo + bare|eelgrass | 8 | 8 | no | accepted | rejected |
sunradiooolong | sun + radio|oolong | 3 | 6 | yes | rejected | rejected |
sunsunradiooolong | sun + sun + radio|oolong | 6 | 6 | no | accepted | rejected |
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
CHECKCOMPOUNDTRIPLEand allow compounds of 3 or more parts (COMPOUNDWORDMAXsets no cap by default; whether a middle part is allowed depends on the dictionary's compound flags or rules). - Real dictionaries. Among 191 public
.afffiles surveyed (local copies of the LibreOffice dictionaries and wooorm/dictionaries),CHECKCOMPOUNDTRIPLEappears 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 anoo-word (oomskuren,oomstridd,oomkullkastlig,oomkullrunkelig) after a part ending ino, such assommarmusikradiooomskurenandsommarpolisradiooomskuren; 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
sommarmusikradioomskurenis rejected by both C Hunspell and Lucene, so only the triple form is newly accepted. - Not affected.
HunspellStemFilterusesStemmer(HunspellStemFilter.java:86,:110-111) andgetRootsreturns 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.
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.
/*
* 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"));
}
}
# 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 rejectsbareeel); actual: true (expected:<false> but was:<true>), with-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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.spellreturns "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-partCHECKCOMPOUNDTRIPLEas unsupported. - Tests. Lucene's fixture
checkcompoundtriple.wronglistsfoooperaandbareeel, run byTestSpellChecking.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 + 1is the port of itsi > 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.
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).
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 andTestSpellChecking), 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.
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.
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.
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_spellper word, and the results were compared with the stock Lucene jar. - For the
checkcompoundtriplefixture, 4 three-part words were accepted only by Lucene:operabareeel,foobareeel,eelbareeel,barebareeel. - Code reading of
mayBreakIntoCompoundsfound 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA; downstream; web | 2026-10-26 | a scripted prior-report search, 51 queries | only LUCENE-9710 and its commit |
| GitHub (apache/lucene, apache/lucene-solr, downstream) | 2026-10-26 | 48 read-only GraphQL searches; all 218 + 94 items matching "hunspell" listed, the 19 compound-related or open ones read | no report |
| JIRA (LUCENE, SOLR), lists.apache.org, web | 2026-10-26 | 6 JIRA REST, 16 list and 8 web searches | no report |
| Open PRs and upstream code | 2026-10-26 | touched files of all 411 open apache/lucene PRs; Hunspell.java on main, branch_10x, branch_10_6; local history | only #16422 touches a hunspell file (Dictionary.java); line 430 unchanged; not fixed |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | gradle: the primary test fails 1 of 2, the three repro files 4 of 60, assertions on and off |
main ea57360912ed | affected | finder, 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 | affected | the JUnit fails 1 of 2 with -ea and -da (release jars) |
| 9.9.0 | fact check | the 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 | affected | main() probe: operabareeel and barebareeel accepted |
| 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4 | affected | main() probe: the same (8.9.0 also with a COMPOUNDBEGIN/MIDDLE/END dictionary and with -da) |
| 8.8.2 | no API | the probe does not compile: no spell-checking class |
parent 40e92315aeaf | not implemented | CHECKCOMPOUNDTRIPLE 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) | bisect | first join and later xx|x checked; operabareeel and barebareeel accepted |
9d45dfe776a2 + the fix | not affected | every triple rejected, controls accepted |
fix A on a9779ef3c193 | not affected | hunspell test package: 165 tests, 0 failures, 13 skipped (they need external dictionaries), assertions on and off; lint passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (differential testing) | candidate | 4 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 | real | Own 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, medium | Intended-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 | real | No 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, high | Re-ran the independent JUnit (-ea, -da: 2 of 5 fail; with the fix 5 of 5 pass). |
| security screen | security none | No 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 | keep | Own probe with other words (kira|aal) failed exactly where the trigger condition predicts; the fix passes. |
| this doc's gradle runs | confirmed | Stock 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, fixed | Re-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 fixed | An 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, fixed | Re-ran the diagram probe and checked every frame against the code. |
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.
- PNG 496 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,249 words
- 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 mainea57360912ed(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 commit9d45dfe776a2, its parent and the commit plus the fix, shadowed over the 9.0.0 jars. Java runs on OpenJDK 25.0.3, Linux aarch64.
- 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-435a 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-474a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-46, 71-74, 427-433a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/checkcompoundtriple.wrong, checkcompoundtriple.good, TestSpellChecking.java:132-134a Lucene main checkout lucene/CHANGES.txt:5368-5369
- TestCheckCompoundTripleLaterBoundary.java (JUnit test):
repro/LFV-373/src/org/apache/lucene/analysis/hunspell/TestCheckCompoundTripleLaterBoundary.java(38 lines)
A picture of the repro's worked example, made in parallel with this doc; the alt text describes the example.
assets/LFV-373/bug.png(496 KB)
