All findings · Lucene finding · LFV-731 · hunspell-compoundrule-parts-skip-entry-checks

Hunspell spell() ignores KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG on COMPOUNDRULE compound parts

When a Hunspell dictionary builds a compound with a COMPOUNDRULE, Lucene's spell checker ignores the restrictions on the compound's parts and accepts words the dictionary forbids and C Hunspell rejects, such as the lower-case Swedish street name tolvkronorsgatan. It fails on every release tested since 8.9.0; stemming is not affected.

Summary

Priority
Low, rank 458The opt-in Hunspell spell-checker accepts COMPOUNDRULE compounds that C Hunspell rejects, a spelling-verdict error that one real public dictionary (sv_SE) hits.
Impact
silent wrong results
Since
8.9.0 (LUCENE-9684, 2021)introduced 2021-01-22 · about 5 years
Affected
8.9.0 to 10.5.2 and main (7 releases and main run)
Status
documented
Found by
differential testing against C Hunspell

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Hunspell dictionaries build compounds with compound flags or with COMPOUNDRULE patterns. Entries can also carry restrictions such as KEEPCASE or FORCEUCASE. Lucene enforces those restrictions on flag compounds and skips them on rule compounds.

Summary diagram. A COMPOUNDRULE dictionary with KEEPCASE K, COMPOUNDRULE GH, foo/GK and bar/H. Expected (C Hunspell 1.7.2 and 1.3.2, and Lucene when the same words are joined by COMPOUNDFLAG): spell("foobar") true, spell("Foobar") and spell("FOOBAR") false. Actual (Lucene main a9779ef3c193): true, true, true, because checkCompoundRules takes foo from a raw dictionary lookup (Hunspell.java:445) without the case check. The rule path also skips NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG on first and middle parts and FORCEUCASE on the last part, as in the Swedish sv_SE example tolvkronorsgatan.
The KEEPCASE example: foo/GK and bar/H joined by COMPOUNDRULE GH. Lucene retries Foobar as foobar with the original case, but only the flag path checks foo's case; the rule path takes foo raw from the dictionary and accepts Foobar and FOOBAR, which C Hunspell rejects. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You load the public Swedish dictionary sv_SE and call spell("tolvkronorsgatan"). A compound rule builds it from tolv (twelve), kronors and gatan ("the street"), and the dictionary marks gatan FORCEUCASE: a compound that ends in it is a street name and must be capitalized.
  2. You expect false: C Hunspell 1.3.2 and 1.7.2 reject it and accept the capitalized Tolvkronorsgatan, and Lucene itself rejects storgatan, which the dictionary builds with compound flags instead of a rule.
  3. Instead spell returns true. Lucene checks a rule compound's parts only against the rule: the first and middle parts are raw dictionary lookups (Hunspell.java:445), and the last part never gets the FORCEUCASE check (:475-493) that flag compounds get (:279).
  4. The same on tiny dictionaries with COMPOUNDRULE GH and bar/H: with foo/GK (KEEPCASE) Lucene accepts Foobar and FOOBAR; with foo/GN (NEEDAFFIX), foo/GW (FORBIDDENWORD) or foo/GF (COMPOUNDFORBIDFLAG) it accepts foobar. C Hunspell 1.7.2 rejects all of them, and so does Lucene when the same words are joined by COMPOUNDFLAG.
  5. No error, with assertions on or off; it fails on every release tested from 8.9.0 to 10.5.2 (7 releases) and on main. suggest() offers such words too: Foobar for the typo Foobarr, where C Hunspell suggests foobar.

Who is affected

Applications that use Lucene's opt-in Hunspell spell checker (Hunspell.spell, suggest) with a COMPOUNDRULE dictionary whose rule entries also carry one of these flags. On the Swedish sv_SE dictionary, lower-case rule compounds that must be capitalized are accepted (run for words ending in gatan, sverige and afrika); the other flags show on synthetic dictionaries only. Stemming, indexing and search are unchanged.

Mechanism, terms and numbers897 words

A Hunspell dictionary can build compound words with compound flags (COMPOUNDFLAG, COMPOUNDBEGIN, ...) or with COMPOUNDRULE patterns over flags. Its entries can also carry restrictions: KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG. Lucene's Hunspell.spell applies these restrictions to the parts of flag compounds but not to the parts of rule compounds: checkCompoundRules (Hunspell.java:437-464 on main a9779ef3c193) takes every non-final part straight from dictionary.lookupWord (:445), and checkLastCompoundPart (:475-493) never applies the FORCEUCASE check. So spell() accepts compounds that C Hunspell 1.7.2 rejects (1.3.2 too, except the COMPOUNDFORBIDFLAG case): on the public Swedish dictionary sv_SE, lower-case rule compounds such as tolvkronorsgatan; on small dictionaries, a KEEPCASE first part in capitalized or upper case and a NEEDAFFIX, FORBIDDENWORD or COMPOUNDFORBIDFLAG entry as a first part (NEEDAFFIX also as a middle part). suggest() offers such words as corrections (run for the KEEPCASE and FORCEUCASE cases). Nothing throws, assertions on and off give the same result, and it fails on all 7 releases run from 8.9.0, the first with the spell-checking API, to 10.5.2; the main-line history shows only refactors of this code in between. Stemming (HunspellStemFilter, getRoots) is not.

Terms

  • Flag compound: a compound accepted through COMPOUNDFLAG or COMPOUNDBEGIN / COMPOUNDMIDDLE / COMPOUNDEND: every part carries a compound flag.
  • Rule compound: a compound accepted because a COMPOUNDRULE pattern over flags matches its parts, for example COMPOUNDRULE GH (a G word, then an H word) or GH* (then any number of H words).
  • Per-entry flags (hunspell(5) of C Hunspell 1.7.2): KEEPCASE "Forbid uppercased and capitalized forms of words signed with KEEPCASE flags"; FORCEUCASE "Last word part of a compound with flag FORCEUCASE forces capitalization of the whole compound word" (the manual's example is the Dutch "straat", street); NEEDAFFIX "virtual stems in the dictionary, words only valid when affixed. Except, if the dictionary word has a homonym or a zero affix"; FORBIDDENWORD "forbidden word form"; COMPOUNDFORBIDFLAG "Dictionary words with this flag are removed from the beginning and middle of compound words".

The bug, step by step (spell("Foobar") with KEEPCASE K, COMPOUNDRULE GH, foo/GK, bar/H)

  1. The word as typed matches nothing (Foo is not in the dictionary), so spell retries the lower-case variant foobar and remembers that the original was title case (Hunspell.java:95-110 on main a9779ef3c193).
  2. checkCompounds (:150-161) tries the rule path first, without the original case: checkCompoundRules(wordChars, 0, length, new ArrayList<>()) (:152). Only the flag path gets it (:157).
  3. checkCompoundRules (:437-464) splits the word and takes the first part's entries straight from the dictionary, IntsRef forms = dictionary.lookupWord(wordChars, offset, breakPos); (:445); middle parts come from the same line in the recursion (:454). Nothing looks at KEEPCASE, NEEDAFFIX, FORBIDDENWORD or COMPOUNDFORBIDFLAG.
  4. checkLastCompoundPart (:475-493) looks the last part up through Stemmer.doStem (:490), which skips NEEDAFFIX entries (Stemmer.java:234-236), but its callback (:481-489) only matches the rules and never calls hasForceUCaseProblem. GH matches, so spell returns true.
checkflag pathrule path
case of the first part (KEEPCASE)findStem with the original case, acceptCase (:163-198)none (:152)
NEEDAFFIX, COMPOUNDFORBIDFLAG on first / middle partsStemmer.doStem (Stemmer.java:234-240)none, raw lookup (:445)
FORBIDDENWORD on first / middle parts:232none (:445)
FORCEUCASE on the last parthasForceUCaseProblem (:279, :288-292)none (:481-489)

The FORBIDDENWORD and COMPOUNDFORBIDFLAG middle-part rows were also executed (fact check: foobarbaz with bar/HW or bar/HF is accepted by stock Lucene and rejected by C 1.7.2 and by the fix).

The numbers

flagwordC Hunspell 1.7.2Lucene, same words as a flag compoundLucene, rule compound
KEEPCASE, first partFoobar, FOOBARfalse, falsefalse, falsetrue, true
FORCEUCASE, last part (tolv/2 kronors/3 gatan/cY, rule 23Y)tolvkronorsgatanfalsefalsetrue
NEEDAFFIX, first partfoobarfalsefalsetrue
NEEDAFFIX, middle part (foo/G bar/HN baz/H, rule GH*)foobarbazfalse-true
FORBIDDENWORD, first partfoobarfalsefalsetrue
COMPOUNDFORBIDFLAG, first partfoobarfalsefalsetrue

C 1.3.2 agrees except COMPOUNDFORBIDFLAG, which it ignores on dictionary words in both modes. The rule path is also inconsistent with itself: in the GH* dictionary the NEEDAFFIX entry bar is rejected as the last part (foobazbar false) and accepted in the middle (foobarbaz true).

Real dictionary (sv_SE). FORCEUCASE c and the rule 4?5?4?23U*Y; 13 entries carry both c and Y, the dictionary's COMPOUNDEND flag, which its rules reuse as their last flag (afrika, danmark, europa, finland, gatan, gränd, holmen, norge, quist, qvist, själland, sverige, tyskland); gatan/AcYZ also has Z = ONLYINCOMPOUND. Lucene accepts tolvkronorsgatan and treveckorssverige; C 1.3.2 and 1.7.2 reject both and accept the capitalized forms, which Lucene accepts too; both reject the flag compound storgatan. Lower-case compounds ending in three of the 13 were run (afrika, gatan, sverige); the other ten were not. These words are built from the dictionary's rules, not taken from a corpus.

Not part of the claim (C and Lucene agree): KEEPCASE on a middle or last rule part, and FORBIDDENWORD on the last rule part, are accepted by both. The claim is per-flag parity with C Hunspell, not "rule and flag compounds always agree".

Exposure and likelihood256 words
  • Who is hit. Code that calls Hunspell.spell or Hunspell.suggest (lucene-analysis-common) with a dictionary that uses COMPOUNDRULE and whose rule-flagged entries also carry KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD or COMPOUNDFORBIDFLAG. The spell checker is opt-in, with a dictionary the application chooses; no default analyzer uses it.
  • What they get. spell says "correct" for words the dictionary forbids; suggest can offer them as corrections (Foobarr gives [Foobar], C 1.7.2 suggests foobar; tolvkronorsgaatan gives [tolvkronorsgatan], C suggests nothing). Only these KEEPCASE and FORCEUCASE cases were run for suggest(). Silent, deterministic, the same with assertions on and off; no scale needed.
  • Real dictionary. sv_SE has 13 entries that are both FORCEUCASE and rule-final (gatan, gränd, holmen and place names such as sverige). Lower-case numeral rule compounds ending in three of them (gatan, sverige, afrika) were run and are accepted; the other ten were not run. How often such words occur in real text was not measured. Two more public dictionaries declare some of these flags next to COMPOUNDRULE; whether their rule entries carry them was not checked: Galician gl_ES (LibreOffice collection: NEEDAFFIX, KEEPCASE, 3 rules) and Dutch nl (wooorm/dictionaries collection: KEEPCASE, FORCEUCASE, FORBIDDENWORD, 37 numeral rules; the LibreOffice nl_NL copy has no COMPOUNDRULE).
  • Not affected. HunspellStemFilter and getRoots: "No roots are returned for compound words" (Hunspell.java:300), so index-time and query-time analysis do not change.
  • Severity. A spell-check verdict error in an opt-in component: silent wrong results, likelihood rare.

Minimal repro

The test shows that Hunspell.spell ignores FORCEUCASE on the last part of a COMPOUNDRULE compound: dictionary COMPOUNDMIN 1, FORCEUCASE U, COMPOUNDRULE GH, entries foo/G bar/HU.

TestCompoundRuleForceUCase.java (JUnit test)39 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.hunspell;

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

public class TestCompoundRuleForceUCase extends LuceneTestCase {

  public void testForceUCaseLastRulePart() throws Exception {
    String aff = "COMPOUNDMIN 1\nFORCEUCASE U\nCOMPOUNDRULE 1\nCOMPOUNDRULE GH\n";
    String dic = "2\nfoo/G\nbar/HU\n";
    Dictionary dictionary =
        new Dictionary(
            new ByteBuffersDirectory(),
            "dict",
            new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
            new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8)));
    // expected false (last part bar is FORCEUCASE: compound must be capitalized), actual true
    assertFalse(
        "spell(\"foobar\") expected false, was true", new Hunspell(dictionary).spell("foobar"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-731/, 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.TestCompoundRuleForceUCase
Expected vs actual
Expected (hunspell(5) FORCEUCASE forces capitalization of the whole compound; C Hunspell rejects it): spell("foobar") is false. Actual: true (spell("foobar") expected false, was true).

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

More files, environment, full output35 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("foobar") expected false, was true
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.analysis.hunspell.TestCompoundRuleForceUCase.testForceUCaseLastRulePart(TestCompoundRuleForceUCase.java:36)

Why it looks unintended

  • hunspell(5) defines KEEPCASE, FORCEUCASE, NEEDAFFIX and COMPOUNDFORBIDFLAG with no compound-flag-only exception; C Hunspell enforces them on rule compounds.
  • Lucene says Hunspell "can be used in place of native Hunspell for many languages" and enforces these flags on flag compounds (keepcase.wrong).
  • The rule path contradicts itself: it rejects a NEEDAFFIX entry as the last part, accepts it in the middle.
The full argument406 words
  • The format. hunspell(5) of C Hunspell 1.7.2 (man/hunspell.5): KEEPCASE (:561-566) "Forbid uppercased and capitalized forms of words signed with KEEPCASE flags"; FORCEUCASE (:496-500) "Last word part of a compound with flag FORCEUCASE forces capitalization of the whole compound word"; NEEDAFFIX (:582-584) "words only valid when affixed. Except, if the dictionary word has a homonym or a zero affix" (an unflagged homonym is a valid word; C 1.7.2, doStem and the fix skip only the flagged homonym); COMPOUNDFORBIDFLAG (:452-455) "Dictionary words with this flag are removed from the beginning and middle of compound words". None is limited to flag compounds. FORBIDDENWORD (:552-556, "This flag signs forbidden word form. Because affixed forms are also forbidden, we can subtract a subset from set of the accepted affixed and compound words") is the weakest: the text forbids whole word forms, compounds included, but does not name compound parts, so it rests on C's behaviour and Lucene's own flag path.
  • Lucene's documentation (Hunspell-rule semantics, broad compatibility). "This class can be used in place of native Hunspell for many languages for spell-checking and suggesting purposes" (Hunspell.java:35-36); its list of unsupported cases (Hungarian, non-BMP characters, PHONE; :39-44) does not cover this. spell returns "whether the given word's spelling is considered correct according to Hunspell rules" (:72).
  • C Hunspell. Two 1.7.2 builds reject every claimed word on the same dictionaries, and 1.3.2 every one except the COMPOUNDFORBIDFLAG case, which 1.3.2 ignores on dictionary words in both kinds of compound (executed). In 1.7.2's compound_check, rule mode skips NEEDAFFIX homonyms, breaks on a FORBIDDENWORD or NEEDAFFIX first word, skips COMPOUNDFORBIDFLAG words and checks FORCEUCASE before returning a rule match; spell() applies KEEPCASE to the returned first part (read). Nuspell's check_compound_with_rules has the same checks (read only).
  • Lucene's own tests. keepcase.wrong lists Unitvv and UNITVV (a KEEPCASE first part of a flag compound) and forceucase.wrong lists foobaz. No fixture combines COMPOUNDRULE with KEEPCASE, FORCEUCASE or NEEDAFFIX.
  • History. LUCENE-9684 (d7968130c3f, apache/lucene-solr#2228) added the raw lookup; its PR says "I've reimplemented Hunspell's logic". LUCENE-9710 (9d45dfe776a) implemented FORCEUCASE on the flag path only; LUCENE-9776 (4b3fb1e0659) moved only the last rule part to doStem, for inflection; LUCENE-9785 (31a64927a46, "Like Hunspell does") kept the case check on the first part of flag compounds. No commit, test or comment says rule compounds skip these checks.
The devil's-advocate check171 words

Strongest case, rated weak by every reviewer: hunspell(5) Note III says "COMPOUNDRULE flags work completely separately from the compounding mechanisms using COMPOUNDFLAG, COMPOUNDBEGIN, etc. compound flags. (Use these flags on different entries for words)." Lucene documents compatibility only "for many languages", no fixture pins this behaviour, and sv_SE reuses its COMPOUNDMIDDLE/COMPOUNDEND flags as rule flags against that note.

Why it fails: Note III is about which compound flags a rule matches, not about per-entry flags such as KEEPCASE or NEEDAFFIX, and FORCEUCASE is defined for any compound. C Hunspell 1.7.2 (and 1.3.2 for all but COMPOUNDFORBIDFLAG) and Nuspell apply the checks in rule mode, Lucene's rule path already applies NEEDAFFIX to the last part, and the synthetic repros reuse no flags and still fail, so it is not a misuse. A note in the manual's BREAK entry ("COMPOUNDRULE handles only the suffixation of the last word part") explains why non-final parts are not affix-stripped, not why their entry flags are ignored; the recommended fix keeps the raw lookup. Verdict: a defect.

Suggested fix

Recommended Give the rule parts the flag path's entry checks

Give rule-compound parts the checks flag-compound parts already get: pass the original case into checkCompoundRules, drop NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG entries from first and middle parts, case-check the first part (KEEPCASE), and skip last-part readings with a FORCEUCASE problem. Validated: the repro passes and the analysis-common tests run 1,915 tests with 0 failures (18 skipped).

All options and trade-offs3 options · 776 words

One fix (A, the finder's) was applied in a scratch worktree at a9779ef3c193 and run through gradle, and shadowed onto the stock jars for every other repro of the hunt. B and C are alternatives that were not run.

Give the rule parts the flag path's entry checks (validated by the checks) (recommended)

Hunspell.java only, +43/-7. checkCompounds passes originalCase to checkCompoundRules (and on through the recursion). A new compoundRulePartForms wraps the raw lookupWord for non-final parts and drops each entry (homonym) with NEEDAFFIX, FORBIDDENWORD or COMPOUNDFORBIDFLAG on its own and, for the first part, entries acceptCase refuses for the word's original case (KEEPCASE). The checkLastCompoundPart callback skips readings for which hasForceUCaseProblem holds (new int entryId overload). The raw lookup stays, so non-final parts are still never affix-stripped.

--- checkCompounds (Hunspell.java:152)
-        && checkCompoundRules(wordChars, 0, length, new ArrayList<>())) {
+        && checkCompoundRules(wordChars, 0, length, new ArrayList<>(), originalCase)) {
--- checkCompoundRules (Hunspell.java:445)
-      IntsRef forms = dictionary.lookupWord(wordChars, offset, breakPos);
+      IntsRef forms =
+          compoundRulePartForms(wordChars, offset, breakPos, words.isEmpty() ? originalCase : null);
--- new: compoundRulePartForms keeps an entry of lookupWord(...) only if
+      !hasFlag(entryId, needaffix) && !hasFlag(entryId, forbiddenword)
+          && !hasFlag(entryId, compoundForbid)
+          && acceptCase(originalCase, entryId, new CharsRef(wordChars, offset, length))
--- checkLastCompoundPart callback (Hunspell.java:481)
+          if (hasForceUCaseProblem(formID, originalCase, wordChars)) {
+            return true;
+          }

Trade-offs

  • One extra pass over the homonyms of each candidate part and a small IntsRef per matched part; no benchmark was run.
  • Homonyms need a stated rule. The fix filters every entry on its own. C 1.7.2 decides by the homonym listed first in the .dic (affixmgr.cxx:1671-1696, :1747-1756) and Lucene's flag path by the first homonym in Lucene's sorted entry order (Stemmer.java:237-240, Hunspell.java:163-184, :232). In a probe on 32 small dictionaries (foo/G plus a flagged foo), the fix accepts foobar/Foobar with a COMPOUNDFORBIDFLAG, FORBIDDENWORD or KEEPCASE homonym in every order; C rejects them when the flagged homonym is listed first, and the flag path when it sorts first. NEEDAFFIX homonyms are skipped one by one everywhere. Homonym order already separates Lucene from C on plain words (FORBIDDENWORD foo), so the PR should state its rule and add a homonym test; fix.diff's helper javadoc holds for single entries only.
  • acceptCase also drops Lucene's hidden case-variant entries (the capitalized copies Lucene adds for mixed-case words and for all-caps words with flags, Dictionary.java:1062-1064, like Hunspell's ONLYUPCASE entries) from middle rule parts, and from the first part unless the word is title or upper case, as the flag path already does (Hunspell.java:166, :186-198); no test covers that case.
  • Forbidden compounds stop being accepted and suggested. A dictionary that relied on the old behaviour relied on something C Hunspell does not do.
  • For the PR, the repro can become fixture files in Lucene's style (.aff / .dic / .good / .wrong, run by TestSpellChecking), one per flag, plus a .sug case.

What the checks validated

  • Stock main a9779ef3c193, gradle: the repro fails 6 of 6 with assertions on and off.
  • With the fix, gradle: the repro passes 6 of 6 with assertions on and off; the whole lucene/analysis/common test task with assertions on, repro included, runs 340 classes, 1,915 tests, 0 failures, 18 skipped (the hunspell package: 47 classes, 164 tests, 13 skipped, because TestAllDictionaries, TestHunspellRepositoryTestCases and TestPerformance need external dictionaries); ecjLint, checkGoogleJavaFormat and forbiddenApis pass with the test in place.
  • With the fix shadowed onto the stock jars: the finder's test 4 of 4, the independent reproducer's test 5 of 5, the second cross-model reviewer's normative test 4 of 4, the credibility audit's 11-dictionary differential (all 99 words agree with C 1.7.2), the sv_SE words (all 8 agree with C 1.7.2) and the suggest() probe (suggestions now match C 1.7.2's). In the hunt, Lucene's 146 hunspell tests and a sweep over 25 upstream compound fixture families pass with the fix.
  • Not run: the full Lucene suite, the external-dictionary suites (TestAllDictionaries, TestHunspellRepositoryTestCases), a benchmark.

Route first and middle parts through Stemmer.doStem, as LUCENE-9776 did for the last part

Use COMPOUND_BEGIN / COMPOUND_MIDDLE contexts so NEEDAFFIX and COMPOUNDFORBIDFLAG are filtered by the stemmer.

Trade-offs

Not run. doStem also strips affixes, so first and middle parts would start matching inflected forms, which hunspell(5) says COMPOUNDRULE does not handle ("handles only the suffixation of the last word part"); it would need a roots-only mode and still needs the case and FORBIDDENWORD checks.

Fix FORCEUCASE only

Only the checkLastCompoundPart hunk of the recommended fix: the one manifestation with real-dictionary evidence (sv_SE).

Trade-offs

Not run. Smallest change, but leaves the KEEPCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG manifestations.

Downstream note

Downstream token filters built on HunspellStemFilter are not affected; only code that calls Lucene's spell-checking API (Hunspell.spell, suggest) gets the wrong verdicts.

How it was found

Differential testing of Lucene's Hunspell.spell against C Hunspell 1.7.2 on generated compounding dictionaries; minimizing the disagreeing dictionaries gave KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG clusters on one code path, checkCompoundRules. A public-API JUnit then reproduced it on the stock jar.

The method in full192 words

Method: cross-implementation differential testing (the hunt's non-formal tier).

  • Inputs: seeded, grammar-generated synthetic dictionaries with one or two compounding directives (COMPOUNDFLAG, COMPOUNDBEGIN/END, COMPOUNDRULE) plus modifiers such as KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG, 1-4 short roots, and every short word over the roots' letters plus 2-3-part concatenations.
  • Lucene's spell() and getRoots() (public API, stock jar) were compared with C Hunspell 1.7.2 built from the pristine source; every disagreement was re-checked on a Linux distribution build of 1.7.2.
  • Greedy one-element-at-a-time minimization of the disagreeing dictionaries gave KEEPCASE (4 dictionaries), FORCEUCASE, NEEDAFFIX, FORBIDDENWORD and COMPOUNDFORBIDFLAG (4) clusters, all on the same code path; the root cause was then read in Hunspell.checkCompoundRules.
  • Found 2026-10-27 on main a9779ef3c193; then checked by an independent reproducer, an intended-behaviour devil's advocate (which showed that hunspell(5) documents COMPOUNDFORBIDFLAG for dictionary words, making it the fifth asserted manifestation, and found the sv_SE case), two executed reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history and prior-report follow-up, a security screen and a credibility audit. This doc added the compact JUnit, the gradle runs, the fix validation, C runs on the repro's dictionaries and the suggest() probe.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): three runs with 153 queries over GitHub, JIRA, the lists and the web, 25 manual searches, all 218 apache/lucene items that mention hunspell, downstream projects, C Hunspell and nuspell, and the changed files of all 410 open PRs. Nothing upstream fixes it.

Searches3 searches

Verdict new: no report by anyone. The closest items are the changes that built the rule path (LUCENE-9684 #10723, LUCENE-9776 #10815), LUCENE-9785 (#10824), which keeps the first-part case check on flag compounds, and flag-path-only fixes. 14 of 18 planned web queries were throttled; keyword search cannot prove absence, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@ lists; JIRA; the web2026-10-27a scripted prior-report search, three query variants (identifiers; symptoms; languages and downstream), 153 queriesno report; closest: #10723, #10815, #10824
apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, nuspell, all of GitHub2026-10-2725 manual searches (each flag name alone, hunspell compound terms); all 218 apache/lucene items that mention hunspell; 48 issue and PR reads; file lists of all 410 open PRsno report; flag-path-only fixes #10749, #10814, #10802, #12220; open #16421/#16422 and #16911 do not touch the fix site; BarMal/Serenity#1276 says Lucene reproduced its COMPOUNDRULE fixtures
JIRA, lists.apache.org, the web2026-10-275 JIRA, 15 list and 4 web queries (14 more web queries throttled)only closed 2021 feature issues and OpenNLP's own Hunspell work

History

Present since COMPOUNDRULE support landed, LUCENE-9684 (d7968130c3f, 2021); the FORCEUCASE form since LUCENE-9710 (9d45dfe776a), both before any release. First released in 8.9.0 through the 8.x backport; fails on every release tested (7, from 8.9.0 to 10.5.2) and on main; the releases in between by code history. Not fixed upstream.

Versions tested8 rows

Introduced by d7968130c3f (LUCENE-9684 "Hunspell: support COMPOUNDRULE", apache/lucene-solr#2228, 2021-01-22, issue #10723): checkCompoundRules looked up every part with a raw lookupWord; git blame of Hunspell.java:445 on main still points to it. 9d45dfe776a (LUCENE-9710, 2021-02-01) implemented FORCEUCASE on the flag path only, which is where the FORCEUCASE form starts. 4b3fb1e0659 (LUCENE-9776, 2021-02-19) moved only the last rule part to doStem, which fixed NEEDAFFIX there and nowhere else. Reached 8.x through the backport c6b964a1157 (apache/lucene-solr#2499, merged 2021-05-28): 8.9.0 is the first release with the spell-checking API. Later main-line changes to both methods are refactors and performance work (af97c5ef379, d537013e708, a454388b80e, ab453b9541a). Hunspell.java and Stemmer.java are unchanged on upstream main a95c754ee003 since ab453b9541ad (2025-02-27).

The commit-level probe compiles each commit's hunspell package over the 9.0.0 jars (not full builds); its last commit column (4b3fb1e0659) equals the 8.9.0 and 9.0.0 release columns on all 14 probes.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe compact JUnit fails 6 of 6 through gradle, assertions on and off; the finder's, the reproducer's and the reviewers' tests fail with -ea and -da
10.0.0, 10.5.2 affectedthe finder's test fails 4 of 4; the compact JUnit fails 6 of 6 on 10.5.2
9.0.0, 9.1.0, 9.12.3 main line from 9.0.0the finder's test fails 4 of 4; the compact JUnit fails 6 of 6 on 9.1.0 and 9.12.3
8.9.0, 8.11.4 first releasethe finder's test (pre-9.1 import) fails 4 of 4
8.8.2 no spell-checking APIthe repro does not compile (no Hunspell class)
d7968130c3f vs its parent (commit probe) introducing committhe parent has no COMPOUNDRULE support; d7968130c3f accepts the KEEPCASE, NEEDAFFIX and FORBIDDENWORD first-part cases while its simple-word controls are correct
9d45dfe776a, 4b3fb1e0659 (commit probe) later changesFORCEUCASE implemented on the flag path only; NEEDAFFIX fixed on the last rule part only
main + recommended fix not affectedthe compact JUnit passes 6 of 6; every other repro of the hunt passes

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests and C Hunspell runs; two executed reviews by a model from another family (OpenAI GPT-5.6) rated it real; an adjudication and a credibility audit re-ran it; this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts433 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed, and C Hunspell rejected the claimed words in every check that ran it.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential against C Hunspell 1.7.2, then the 4-test JUnit: fails on main (-ea, -da), 10.5.2 and 9.12.3; C 1.7.2 (two builds) rejects the same words on twin dictionaries; the fix passes.
cross-model review, refute lens (OpenAI GPT-5.6) real, high; intended argument weakOwn 5-test JUnit fails 4 of 5 (the fifth is a control) on main ea57360912ed (identical hunspell package); an earlier 3-test version (2 failing) repeated over 3 iterations with assertions on and off; passes with the fix; a sweep over 25 upstream compound fixture families passes stock and with the fix.
independent reproducer realOwn JUnit with other words and flags fails 5 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; C 1.3.2 and 1.7.2 reject every claimed word; found that a FORBIDDENWORD last part is accepted by C too.
cross-model review, intended lens (OpenAI GPT-5.6) real, high; intended argument weakNormative JUnit fails 4 of 4 and passes with the fix; a separate run re-ran it on a9779ef3c193 (-ea and -da).
intended-behaviour devil's advocate real, highRead the matching checks in C 1.7.2, Hunspell master and Nuspell; showed that hunspell(5) documents COMPOUNDFORBIDFLAG for dictionary words, which made it the fifth asserted manifestation; found the sv_SE case (tolvkronorsgatan accepted by Lucene, rejected by C 1.7.2).
adjudication confirmed, highRe-ran the independent reproduction's JUnit: 5 of 5 fail with -ea and -da, 5 of 5 pass with the fix.
credibility audit keepOwn 11-dictionary differential against C 1.7.2: 6 rule dictionaries with only false accepts, all 5 flag twins agree; sv_SE confirmed against C 1.3.2 and 1.7.2; found that C 1.3.2 ignores COMPOUNDFORBIDFLAG on dictionary words.
this doc reproduced; fix validatedCompact JUnit fails 6 of 6 through gradle on stock (assertions on and off) and passes with the fix; analysis-common: 1,915 tests, 0 failures with the fix; C 1.7.2 (two builds) and 1.3.2 on its 11 dictionaries; suggest() shown affected and fixed.
fact checks of this doc (independent, and a model from another family, OpenAI GPT-5.6) sound; wording fixedThe independent check re-ran the final test file on main, 10.5.2, 9.12.3 and 9.1.0 and with the fix, every table cell, sv_SE and C Hunspell; the cross-model check read the code, logs and sources. One citation gap and several overclaims were fixed, and a homonym probe was added for the fix.

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

Finding record

LFV-731, snapshot of 2026-11-06

Registry title
Hunspell spell() skips the per-entry checks on COMPOUNDRULE compound parts, so it accepts compounds that C Hunspell rejects: a KEEPCASE first part in capitalized or upper case, a FORCEUCASE last part in lower case, and a NEEDAFFIX or FORBIDDENWORD entry as a first or middle part
Area
analysis / hunspell (Hunspell.spell compound checking, COMPOUNDRULE)
Priority
Low, rank 458: The opt-in Hunspell spell-checker accepts COMPOUNDRULE compounds that C Hunspell rejects, a spelling-verdict error that one real public dictionary (sv_SE) hits.
Impact class
silent wrong results
Impact note
Only applications that call the opt-in Hunspell spell-checker (Hunspell.spell; suggest() validates its candidates with the same check but was not executed) with a COMPOUNDRULE dictionary whose rule-flagged entries also carry KEEPCASE, FORCEUCASE, NEEDAFFIX, FORBIDDENWORD or COMPOUNDFORBIDFLAG are hit: spell() returns true for misspelled compounds that C Hunspell rejects, with no exception, the same with -ea and -da and with no scale needed; the public sv_SE dictionary shows it for FORCEUCASE (lower-case numeral rule compounds that end in one of its 13 FORCEUCASE entries, such as tolvkronorsgatan), the other flags are shown only on synthetic dictionaries, stemming and analysis (HunspellStemFilter, getRoots) are not affected, and every release from 8.9.0 to 10.5.2 has it.
Summary
You load a Hunspell dictionary that joins foo/G and bar/H by 'COMPOUNDRULE GH', give foo or bar one of the per-entry flags KEEPCASE, FORCEUCASE, NEEDAFFIX or FORBIDDENWORD, call Hunspell.spell, and expect the answer C Hunspell 1.3.2 and 1.7.2 give (and Lucene itself gives when the same parts are joined by COMPOUNDFLAG): Foobar and FOOBAR rejected when foo is KEEPCASE, foobar rejected when bar is FORCEUCASE, and foobar rejected when foo is a NEEDAFFIX virtual stem or a FORBIDDENWORD entry. Instead spell() returns true in all four cases, with and without -ea, in every release since 8.9.0; on the public Swedish dictionary sv_SE it accepts lower-case rule compounds such as tolvkronorsgatan that the dictionary's FORCEUCASE flag and C Hunspell reject. The cause: checkCompoundRules takes every non-final part as a raw dictionary.lookupWord result, without the NEEDAFFIX, FORBIDDENWORD, COMPOUNDFORBIDFLAG and first-part case (KEEPCASE) filters, and checkLastCompoundPart never applies the FORCEUCASE check (hasForceUCaseProblem) that the COMPOUNDFLAG path uses.
Introduced
d7968130c3f5, LUCENE-9684 'Hunspell: support COMPOUNDRULE' (apache/lucene-solr#2228, by donnerpeter; issue apache/lucene#10723): it added checkCompoundRules with raw dictionary.lookupWord lookups for the parts and no case argument; git blame on main still points Hunspell.java:445 to it [read: git blame and git show in the history clone] [exec: commit-level probe, the hunspell package of each commit compiled over the 9.0.0 jars: the parent cf5db8d6513 has no COMPOUNDRULE support; d7968130c3f accepts the KEEPCASE-first, NEEDAFFIX-first, NEEDAFFIX-last and FORBIDDENWORD-first rule compounds while its simple-word KEEPCASE/NEEDAFFIX/FORBIDDENWORD controls are correct]. The FORCEUCASE form starts at 9d45dfe776a (LUCENE-9710, apache/lucene-solr#2272), which implemented FORCEUCASE on the COMPOUNDFLAG path only (before it Lucene ignored FORCEUCASE everywhere); 4b3fb1e0659 (LUCENE-9776, apache/lucene-solr#2397) moved only the last rule part to Stemmer.doStem, which fixed NEEDAFFIX for the last part only [exec: commit-level probe]. Reached 8.x through the backport c6b964a1157 (apache/lucene-solr#2499, LUCENE-9687, merged 2021-05-28). Present since the feature's first commit, before any release; not a regression (2021-01-22 (main line, d7968130c3f); 2021-02-01 for the FORCEUCASE form (9d45dfe776a); 2021-05-28 (8.x backport))
First affected version
8.9.0 [exec] (the first release with the spell-checking API, through the 8.x backport; 8.8.2 has no Hunspell class [exec]); on the main line 9.0.0 [exec]
Affected versions
every release that has the spell-checking API: 8.9.0 - 8.11.4, 9.0.0 - 9.12.3 and 10.0.0 - 10.5.2, and main a9779ef3c193. Executed and failing 4 of 4 (the four claim assertions; no control fails): 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) [exec, follow-up; 8.x and 9.0.0 with the same test importing the pre-9.1 LuceneTestCase package]; the independent reproduction independently executed main, 10.5.2 and 9.12.3 with its own test, and the cross-model reviews executed 10.5.2 and 9.12.3 [exec]. The other releases [read]: after 4b3fb1e0659, git log -L on checkCompoundRules and checkLastCompoundPart shows only refactors and performance changes (af97c5ef379, d537013e708, a454388b80e, ab453b9541a). Not affected: 8.8.2 and older (no spell-checking API) [exec 8.8.2]. Not fixed upstream: Hunspell.java and Stemmer.java last changed in ab453b9541ad (2025-02-27) on upstream main a95c754ee003 (2026-10-26), and no open PR touches them [read]. All runs on Linux aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-27, read-only, the prior-report record verdict new: 3 scripted prior-report searches with 153 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 25 own GitHub searches (all 218 apache/lucene items that mention hunspell, each flag name alone, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, nuspell, all of GitHub), 48 issue and PR reads, a listing of all 410 open apache/lucene PRs with their file lists, 15 list queries, 5 JIRA queries and 4 web queries; 22 hits judged, none the same bug; the finder's and the intended-behaviour check's (51 queries) earlier searches agree). Closest items: #10723 (LUCENE-9684, apache/lucene-solr#2228), the introducing change, whose PR says Hunspell's logic was reimplemented and whose reviewer said they did not verify it against Hunspell; #10815 (LUCENE-9776, apache/lucene-solr#2397), which moved only the last rule part to Stemmer.doStem; #10824 (LUCENE-9785, 'Like Hunspell does'), which keeps the case check on the first COMPOUNDFLAG part only; #10749 (LUCENE-9710), #10814 (LUCENE-9775), #10802 (LUCENE-9763) and #12220, fixes on the COMPOUNDFLAG path only; open #16421/#16422 (Dictionary.java directive-parsing refactor) and #16911 (branch_10x decommission), neither a fix. Not fixed upstream: Hunspell.java and Stemmer.java unchanged since ab453b9541ad (2025-02-27) on upstream main a95c754ee003 (2026-10-26). Gap: 14 of 18 planned web queries were not run because the search service throttled.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit through gradle in a scratch worktree created clean at that commit, stock and with the recommended fix, assertions on and off, plus the lucene/analysis/common test task and its lint checks with the fix; through the hunt's classpath runner, the final JUnit on 10.5.2, 9.12.3 and 9.1.0 (-ea) and main (-ea, -da, re-run by the fact check), and a suggest() probe stock and with the fix shadowed; C Hunspell 1.7.2 (pristine source build and a Linux distribution build) and 1.3.2 on every dictionary of the repro, and a homonym probe on 32 small dictionaries (2026-10-28). Hunt runs on main a9779ef3c193 and ea57360912ed (identical hunspell package), on 7 releases from 8.9.0 to 10.5.2 (-ea), and a commit-level probe over 10 commits; the public sv_SE dictionary against C 1.3.2 and 1.7.2.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java at a9779ef3c193: :34-47 class javadoc ('can be used in place of native Hunspell for many languages'), :71-74 spell javadoc ('considered correct according to Hunspell rules'), :150-161 checkCompounds (originalCase passed only to the COMPOUNDFLAG path), :163-184 findStem with acceptCase, :186-198 acceptCase (KEEPCASE), :213-247 and :269-286 the COMPOUNDFLAG path (forbiddenword checks at :232 and :277, hasForceUCaseProblem at :279), :288-292 hasForceUCaseProblem, :437-464 checkCompoundRules (raw dictionary.lookupWord at :445), :475-493 checkLastCompoundPart (Stemmer.doStem, no FORCEUCASE check)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java at a9779ef3c193: :227-243 doStem (NEEDAFFIX entries skipped; COMPOUNDFORBIDFLAG roots refused only in COMPOUND_BEGIN/MIDDLE)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/keepcase.aff, keepcase.dic (uni/ZC with COMPOUNDFLAG C) and keepcase.wrong (Unitvv, UNITVV): Lucene's own fixture rejects a KEEPCASE first part of a COMPOUNDFLAG compound in capitalized and upper case
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (lines 95-110, 150-198, 232, 279, 288-292, 437-493 at a9779ef3c193)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java (lines 227-243)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (line 300)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (lines 35-44, 72)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/keepcase.aff, keepcase.dic, keepcase.wrong (lines 10-11), forceucase.aff, forceucase.dic, forceucase.wrong

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

Repro files

About the diagrams

The diagram uses the KEEPCASE dictionary as its main example and ends with the sv_SE FORCEUCASE case; the compact repro covers all five flags.

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.