All findings · Lucene finding · LFV-724 · hunspell-compoundwordmax-one-allows-two-word-compounds

Hunspell spell() accepts two-word flag compounds when COMPOUNDWORDMAX is 1 (or 0)

With COMPOUNDWORDMAX 1 a Hunspell dictionary allows one word per compound, yet Lucene's Hunspell.spell accepts foobar, built from foo and bar, which C Hunspell rejects. The word-count check skips the first part of a compound; every release since 8.9.0 is affected, stemming is not.

Summary

Priority
Low, rank 468Only the opt-in Hunspell spell() verdict is wrong, only with a self-contradictory COMPOUNDWORDMAX 0 or 1 that no public dictionary uses, and stemming is unaffected.
Impact
minor
Since
8.9.0 (LUCENE-9729, 2021)introduced 2021-02-05 · about 5 years
Affected
8.9.0 to 10.5.2 and main
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

COMPOUNDWORDMAX n caps the number of words in a compound. Lucene counts a compound's parts only from the second part on, so a cap of 1 never fires: two-word compounds pass, three-word ones are still rejected. Caps of 2 or more are exact.

Summary diagram. A Hunspell dictionary with COMPOUNDFLAG C, COMPOUNDMIN 1, COMPOUNDWORDMAX 1 and the words foo/C and bar/C. Expected (C Hunspell 1.3.2 and 1.7.2): spell("foobar") false, because a two-word compound exceeds a limit of one word. Actual (Lucene main a9779ef3c193): true, because the word-count check in checkCompounds (Hunspell.java:214) runs only when a previous part exists, so the first part foo is never counted and bar is accepted as the last part. With COMPOUNDWORDMAX 2 both agree: foobar true, foobarfoo false.
The foo/bar dictionary at COMPOUNDWORDMAX 1: Lucene splits foobar into foo and bar, never counts the first part, and accepts the compound; C Hunspell counts both parts and rejects it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You load a dictionary with COMPOUNDFLAG C, COMPOUNDMIN 1 and the words foo/C and bar/C, so foobar is a two-word compound. With COMPOUNDWORDMAX 2, Lucene accepts foobar and rejects the three-word foobarfoo, as it should.
  2. You set COMPOUNDWORDMAX 1 (at most one word per compound) and call spell("foobar"). You expect false: C Hunspell 1.3.2 and 1.7.2 return false.
  3. Instead Lucene returns true. It still rejects foobarfoo, so only two-word compounds slip through. COMPOUNDWORDMAX 0 and COMPOUNDBEGIN/COMPOUNDEND compounds behave the same.
  4. Why: the count check at the top of checkCompounds (Hunspell.java:214) runs only when there is a previous part. Nothing counts the first part, and checkCompoundsAfter accepts first part + last part without a check, so a limit of 1 never fires.
  5. The original code (LUCENE-9699) rejected every flag compound at 1; a refactoring a week later dropped that check, before any release. No public dictionary found uses 0 or 1.

Who is affected

Applications that use Lucene's opt-in Hunspell spell checker (Hunspell.spell, suggest) with a dictionary that combines compound flags with COMPOUNDWORDMAX 1 or 0. None of 477 public .aff files with the directive uses those values, so the reach is small. Lucene's stemming (Stemmer, HunspellStemFilter) is unchanged; code that calls spell or suggest in its own pipeline sees the bug.

Mechanism, terms and numbers647 words

COMPOUNDWORDMAX n in a Hunspell affix file caps how many words a compound may have (hunspell(5): "Set maximum word count in a compound word"). Lucene's spell checker checks the cap only before it adds a third or later part: the check at the top of checkCompounds (Hunspell.java:214 on main a9779ef3c193) is skipped when there is no previous part, and checkCompoundsAfter (:269-286) accepts the first part plus a last part without counting. For a cap of 2 or more that is exact, but a cap of 1 never fires for a two-word compound. So with COMPOUNDFLAG C, COMPOUNDMIN 1, COMPOUNDWORDMAX 1 and the words foo/C, bar/C, spell("foobar") returns true, while C Hunspell 1.3.2 and 1.7.2 return false. COMPOUNDWORDMAX 0 (which the parser clamps to 1) and COMPOUNDBEGIN/COMPOUNDEND compounding behave the same; three or more words are still rejected. The code that added the directive (LUCENE-9699) rejected every flag compound at 1; the LUCENE-9729 refactoring dropped the first-part check before any release, so every release with the spell-checking API, 8.9.0 to 10.5.2, is affected. The reach is small: none of 477 public .aff files with the directive uses 0 or 1, and Lucene's stemming is not affected.

Terms

  • Flag compound: a word Lucene accepts because its parts carry COMPOUNDFLAG, or COMPOUNDBEGIN / COMPOUNDMIDDLE / COMPOUNDEND. COMPOUNDMIN is the shortest allowed part (default 3).
  • COMPOUNDWORDMAX n: hunspell(5) "Set maximum word count in a compound word. (Default is unlimited.)". Lucene parses it as Math.max(1, n) (Dictionary.java:439), so 0 acts as 1; the default is Integer.MAX_VALUE (:152).

What happens in spell("foobar") at COMPOUNDWORDMAX 1

  1. foobar is not a dictionary word, so checkCompounds(char[], ...) (Hunspell.java:150-161) takes the flag path and calls checkCompounds(word, originalCase, null) (:157): no previous part.
  2. That method starts with the only word-count check in the package's production code (:214): if (prev != null && prev.index > dictionary.compoundMax - 2) return false;. prev is null, so nothing is checked.
  3. foo is found as a first part and wrapped in a CompoundPart with index 1 (:372); checkCompoundsAfter is called (:234-236).
  4. checkCompoundsAfter (:269-286) finds bar as a valid last part and returns true (:276-281). It checks forbidden words, CHECKCOMPOUNDDUP, FORCEUCASE and mayCompound, but not the count.
  5. Only when the rest is not a valid last part does it recurse with prev = foo (:285); then 1 > 1 - 2 rejects a third part.

So for a limit n >= 2 the check rejects exactly the (n+1)-th part, and for n = 1 the second, last part is never counted.

What C Hunspell does

In compound_check the second word is accepted only if (cpdwordmax == -1) || (wordnum + 1 < cpdwordmax) (1.7.2 affixmgr.cxx:1928 and :2067; the same condition in compound_check_morph at :2516 and :2655), with wordnum 0 at the first call (hunspell.cxx:895). So 0 and 1 reject every two-word flag compound, unless the Hungarian COMPOUNDSYLLABLE escape on the same line applies, which Lucene does not implement.

Measured

COMPOUNDWORDMAXwordpartsC Hunspell 1.7.2Lucene main
2foobar2truetrue
2foobarfoo3falsefalse
1foo1truetrue
1foobar2falsetrue
1foobarfoo3falsefalse

Over grids with both flag styles, limits absent, 0, 1, 2 and 3, and words of 1 to 4 parts, Lucene and C disagree only on the two-word compound at 0 and 1 (70 cells against C 1.7.2: 4 mismatches; 48 comparisons against C 1.3.2: 6). C 1.7.2 and 1.3.2 both reject the exact foobar at 0 and 1 and accept it at 2, in both flag styles. suggest() uses the same check: at limit 1 it offered the over-limit compound northstar for the typo northstarr (not compared with C's suggestions).

Exposure and likelihood277 words
  • Who is hit. Callers of Hunspell.spell and Hunspell.suggest (lucene-analysis-common) with a dictionary that uses flag compounding and sets COMPOUNDWORDMAX 1 or 0. The checker is opt-in: the application loads a dictionary of its choice, and no default analyzer uses it.
  • What they get. spell says "correct" for every two-word flag compound the dictionary can build, which is exactly what the author tried to forbid; suggest can offer such compounds. No exception, the same result with assertions on and off, deterministic, no scale needed.
  • How likely. Low. The setting is self-contradictory (declare compound flags, then allow one word per compound). A code search found 477 public .aff files with COMPOUNDWORDMAX, none with 0 or 1 (values: 4 in 227 files, 2 in 148, 10 in 71, 3 in 20, 100 in 7, 7 in 4); it covers only what the search indexes. A maintainer who sets 1 to switch flag compounding off without removing the flags would hit it; no such dictionary is known.
  • Not affected. Limits of 2 or more and the default (unlimited); three or more words at 1; COMPOUNDRULE compounds (compoundMax is never consulted there, and C Hunspell 1.3.2 also accepts two rule parts at 1); Lucene's stemming: Stemmer stems only simple words (Stemmer.java:94-103) and HunspellStemFilter calls it (HunspellStemFilter.java:108-114), so it never splits compounds ("No roots are returned for compound words", Hunspell.java:300). Code that calls spell or suggest in its own indexing or query pipeline does see the bug.
  • Severity. A spell-check verdict error in an opt-in component for an unusual setting: impact minor, likelihood contrived, priority low. Best framed as a small consistency and C-parity fix.

Minimal repro

The test shows that with COMPOUNDFLAG C, COMPOUNDMIN 1, COMPOUNDWORDMAX 1 and the words foo/C, bar/C, Hunspell.spell("foobar") accepts the 2-word compound.

TestCompoundWordMaxOne.java (JUnit test)40 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 TestCompoundWordMaxOne extends LuceneTestCase {

  public void testTwoWordCompoundAtMaxOne() throws Exception {
    String aff = "COMPOUNDFLAG C\nCOMPOUNDMIN 1\nCOMPOUNDWORDMAX 1\n";
    String dic = "2\nfoo/C\nbar/C\n";
    Dictionary dictionary =
        new Dictionary(
            new ByteBuffersDirectory(),
            "dict",
            new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
            new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8)));
    // hunspell(5): COMPOUNDWORDMAX = maximum word count in a compound; expected false, actual true
    assertFalse(
        "COMPOUNDWORDMAX 1: spell(\"foobar\") (2 words) expected false, was true",
        new Hunspell(dictionary).spell("foobar"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-724/, 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.TestCompoundWordMaxOne
Expected vs actual
Expected: false (hunspell(5): COMPOUNDWORDMAX is the maximum word count in a compound; C Hunspell rejects it); actual (-ea and -da): true, because checkCompounds never counts the first part.

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

More files, environment, full output39 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: COMPOUNDWORDMAX 1: spell("foobar") (2 words) 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.TestCompoundWordMaxOne.testTwoWordCompoundAtMaxOne(TestCompoundWordMaxOne.java:36)

Why it looks unintended

  • hunspell(5): "Set maximum word count in a compound word." C Hunspell 1.3.2 and 1.7.2 reject foobar at 1 and at 0.
  • Lucene enforces limits of 2 and 3 exactly, and at 1 it still rejects three words.
  • The original LUCENE-9699 code rejected every flag compound at 1; the LUCENE-9729 refactoring dropped that check without discussion.
The full argument416 words
  • The format defines a word count. hunspell(5) of C Hunspell 1.7.2: "COMPOUNDWORDMAX number: Set maximum word count in a compound word. (Default is unlimited.)". A two-word compound exceeds a maximum of 1.
  • Lucene aims at Hunspell behaviour. The class javadoc says the class "can be used in place of native Hunspell for many languages for spell-checking and suggesting purposes" (Hunspell.java:35-36). The promise is qualified ("Note that not all languages are supported yet. For example:" Hungarian, non-BMP characters, PHONE; :36-44); the list is not exhaustive, but none of its entries covers a compound word limit. spell returns "whether the given word's spelling is considered correct according to Hunspell rules" (:72).
  • Lucene is inconsistent with itself. At 2, foobar is accepted and foobarfoo rejected (this doc's control test, the independent reproducer and the devil's advocate); at 3, three parts are accepted and four rejected (the last two); at 1, three words are rejected. Only the two-word case at 1 (and 0) differs.
  • C Hunspell rejects it. 1.3.2 and 1.7.2 reject foobar at 1 and at 0 and accept it at 2, in both flag styles; a second 1.7.2 build (a Linux distribution package) agrees at 1 and 2. This follows wordnum + 1 < cpdwordmax with wordnum 0 at the first part; the rule's Hungarian COMPOUNDSYLLABLE escape is not set in these dictionaries.
  • The original code enforced 1. LUCENE-9699 (a176308aa63, 2021-01-27, apache/lucene-solr#2248, issue #10738) added the directive with if (depth > dictionary.compoundMax - 2) return false;, run with depth 0 at the first part, so a limit of 1 rejected every flag compound (COMPOUNDRULE compounds were checked first, on a path without a limit). LUCENE-9729 (650f16ad5d8, 2021-02-05, apache/lucene-solr#2300, issue #10768, "Hunspell: support CHECKCOMPOUNDREP flags") replaced the depth counter by the CompoundPart chain and wrote the current guard; prev != null is the null guard that prev.index needs. The PR body, its single review comment (about CheckCompoundPattern offsets) and the issue never mention COMPOUNDWORDMAX. Commit-level runs: the parent 16764f16010 enforces 1, 650f16ad5d8 does not.
  • Not a listed divergence. Lucene's test over C Hunspell's own test cases names the cases it expects to fail, TestHunspellRepositoryTestCases.EXPECTED_FAILURES (hu, morph, opentaal_keepcase, nepali, utf8_nonbmp, phone). That is an exception set for one test corpus, not a full catalogue of accepted differences; this case is not in it. Lucene's tests use COMPOUNDWORDMAX only with the value 2, as a parse check (forgivable-errors.aff, TestDictionary.testForgivableErrors); no test exercises the limit in spell.
The devil's-advocate check271 words

The strongest case for "intended": (a) a limit of 1 is degenerate, because a compound has at least two words and an author who wants none would not declare a compound flag; (b) hunspell(5) presents COMPOUNDWORDMAX among the options for the Hungarian "6-3" rule (with COMPOUNDSYLLABLE, COMPOUNDROOT and SYLLABLENUM), and Lucene does not support Hungarian; C's count condition itself has a COMPOUNDSYLLABLE escape; (c) the Math.max(1, n) clamp could suggest that values below 2 were never meant to limit anything; (d) on the COMPOUNDRULE path C Hunspell 1.3.2 and Lucene both accept two parts at 1; (e) Lucene's parity promise is qualified ("for many languages", exceptions given "for example"), and every release has behaved this way.

Why it fails: (a) is about impact, not intent, and is reflected in the "minor" rating; the directive takes any number and the man page gives no lower bound. (b) Lucene parses and enforces COMPOUNDWORDMAX for every dictionary, and the trigger uses no Hungarian option. (c) The clamp came with LUCENE-9699, whose check did enforce 1 (0 and 1 behaved alike, as in C), so it never meant "no limit". (d) This report is about flag compounds only, where C enforces the limit and Lucene enforces it for 2 and 3. (e) A qualified promise does not make a silently lost check intended; the change came in a refactoring that never discussed the limit. Every reviewer rated the intended argument weak as a case that today's result is correct; the cross-model fact check rated it moderate as a "won't fix / very low priority" argument, so keep the issue small. Verdict: not intended, a defect.

Suggested fix

Recommended Check the limit for the first part too

Check the limit for the first part too: in checkCompounds, if (prev == null ? compoundMax < 2 : prev.index > compoundMax - 2) return false;. Then 0 and 1 reject every flag compound, as in C. Validated: the repro passes, and the lucene/analysis/common tests run 1,913 tests with 0 failures (18 skipped).

   private boolean checkCompounds(CharsRef word, WordCase originalCase, CompoundPart prev) {
-    if (prev != null && prev.index > dictionary.compoundMax - 2) return false;
+    if (prev == null ? dictionary.compoundMax < 2 : prev.index > dictionary.compoundMax - 2) {
+      return false;
+    }
All options and trade-offs3 options ยท 550 words

One fix (A, the finder's one-line change) 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.

Check the limit for the first part too (validated by the checks) (recommended)

Hunspell.java only, +3/-1. With no previous part, the compound being built has at least two words, so it is allowed only if compoundMax >= 2. This restores the LUCENE-9699 behaviour (depth > compoundMax - 2 with depth 0) and matches C's wordnum + 1 < cpdwordmax with wordnum 0; later parts are unchanged. The check runs before the CHECKCOMPOUNDPATTERN replacements, so they are limited the same way. The COMPOUNDRULE path is untouched, and for 0 and 1 the Math.max(1, n) clamp needs no change. Open point, negative values: C treats -1 as "unlimited" (atoi, affixmgr.cxx:129, :1928), Lucene clamps every value below 1 to 1 (Dictionary.java:439). Executed: at -1, C 1.7.2 and 1.3.2 accept every compound, main accepts two words but rejects three, and with the fix every flag compound is rejected; at -2 the fix matches C. No surveyed dictionary uses a negative value; mapping -1 to unlimited would need its own decision and test.

   private boolean checkCompounds(CharsRef word, WordCase originalCase, CompoundPart prev) {
-    if (prev != null && prev.index > dictionary.compoundMax - 2) return false;
+    if (prev == null ? dictionary.compoundMax < 2 : prev.index > dictionary.compoundMax - 2) {
+      return false;
+    }

Trade-offs

One comparison per top-level call; no benchmark was run. Dictionaries with COMPOUNDWORDMAX 0 or 1 and compound flags stop accepting two-word compounds, which is what the directive says and what C Hunspell does.

What the checks validated

  • Stock main a9779ef3c193, gradle: the repro fails 3 of 4 (the claim tests) with assertions on and off.
  • With the fix, gradle: the repro passes 4 of 4 with assertions on and off; the whole lucene/analysis/common test task with assertions on, repro included, runs 340 classes, 1,913 tests, 0 failures, 18 skipped (the hunspell package: 47 classes, 162 tests, 13 skipped, because TestAllDictionaries, TestHunspellRepositoryTestCases and TestPerformance need external dictionaries; TestSpellChecking, 53 tests, passes); checkGoogleJavaFormat, ecjLint, forbiddenApisMain and forbiddenApisTest pass with fix and test in place.
  • Classpath runner with the fix shadowed: this doc's test 4 of 4; the finder's test; the independent reproducer's 8 of 8; a 70-cell grid against C Hunspell 1.7.2 with 0 mismatches; 810 .good/.wrong expectations over 53 upstream dictionaries unchanged; a separate, narrower JUnit 4 class-path run of Lucene's hunspell test classes (all but TestHunspellRepositoryTestCases) passes 146 tests, a count not comparable with gradle's.
  • Not run: the full Lucene suite, the external-dictionary suites, a benchmark.

Count the last part in checkCompoundsAfter

Before accepting the last root (Hunspell.java:276-281), require prev.index + 1 <= compoundMax.

Trade-offs

Same effect, but it splits the limit check across two methods, and CHECKCOMPOUNDPATTERN replacements reach checkCompoundsAfter too. Not run.

Skip flag compounding when compoundMax < 2

Add && dictionary.compoundMax >= 2 to the flag-path condition in checkCompounds(char[], ...) (Hunspell.java:156).

Trade-offs

Smallest reasoning about the degenerate value, but it leaves the misleading first-part gap in the guard at :214. Not run.

Downstream note

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

How it was found

Differential testing of Lucene's Hunspell.spell against C Hunspell 1.7.2 on generated compounding dictionaries with COMPOUNDWORDMAX 1, 2 and 3: all 11 disagreements were two-word compounds at limit 1, and limits 2 and 3 never disagreed. The case was then minimized into a public-API JUnit on the stock jar.

The method in full147 words

Method: cross-implementation differential testing (not a formal method). A grammar generated small synthetic compounding dictionaries, with COMPOUNDWORDMAX drawn from {1, 2, 3}, and compared the stock jar's Hunspell.spell with C Hunspell 1.7.2 on the generated words.

  • 11 disagreements came out (9 dictionaries of one seed, 2 of another), all with COMPOUNDWORDMAX 1 and a two-part compound; limits 2 and 3 never disagreed.
  • The finder minimized them to the foo/bar dictionary, wrote a public-API JUnit, ran it on main (-ea, -da), 10.5.2 and 9.12.3, and checked C Hunspell 1.7.2 (a build from the release source, with the hunt's test hooks off, and a Linux distribution build) on the same files.
  • Before submitting, an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute the claim with its own test; it agreed, and pointed out that the original LUCENE-9699 code enforced a limit of 1.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 129 query records over GitHub issues, PRs and commits, JIRA, the dev@, java-user@, issues@ and commits@ lists, downstream projects and the web, plus a listing of all 410 open PRs and the changed files of the 16 analysis PRs. Not fixed upstream.

Searches3 searches

Closest items, none the same bug: #10768 (LUCENE-9729, the introducing change), #10738 (LUCENE-9699, which added the directive and enforced 1), #10726 (LUCENE-9687, the umbrella issue, with its 8.x backport apache/lucene-solr#2499), open #16422 (a stale Dictionary.java parse refactor that keeps the clamp) and #16911 (deletes the branch_10x tree). An outside spell checker (BarMal/Serenity#1198) read Lucene's Hunspell.java while implementing COMPOUNDWORDMAX and does not mention a limit of 1. Limits: keyword searches cannot prove absence, private channels are not visible, and open PRs outside the 16 analysis PRs, or opened after the 2026-10-27 listing, were not inspected file by file.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories (Elasticsearch, OpenSearch, Solr); dev@, java-user@, issues@ and commits@; JIRA; the web2026-10-27a scripted prior-report search, two runs, 110 records (57 and 53; run B's 6 web queries only planned, as they overlap run A's) (identifiers such as COMPOUNDWORDMAX, compoundMax, checkCompounds, CompoundPart; symptom phrases)no report; repo:apache/lucene COMPOUNDWORDMAX 0 results; COMPOUNDWORDMAX 0 mails on dev@, java-user@ and issues@ (9 commit-diff mails on commits@); JIRA 0
GitHub, the web and git history (manual)2026-10-2719 records (14 GitHub and git-history, 5 web searches), among them a listing of all 410 open apache/lucene PRs and the file lists of the 16 analysis PRsof the 16 analysis PRs only #16422 and #16911 touch hunspell sources; neither changes the check on main (#16911 deletes the branch_10x tree)
earlier independent searches2026-10-27Verifiers' own prior-art runs (9 and 43 queries)no report

History

Introduced by LUCENE-9729 (650f16ad5d8, 2021-02-05), a refactoring that dropped the first-part check LUCENE-9699 had added a week earlier; both predate any release. First released in 8.9.0 through the 8.x backport; fails on every release tested from 8.9.0 to 10.5.2 and on main. Not fixed upstream.

Versions tested9 rows

Introduced by 650f16ad5d8 "LUCENE-9729: Hunspell: support CHECKCOMPOUNDREP flags (#2300)" (@donnerpeter, 2021-02-05, issue #10768), in its refactoring sub-commit f78cc2563024 (2021-02-03). It replaced if (depth > dictionary.compoundMax - 2) return false; (from LUCENE-9699, a176308aa63, 2021-01-27, with depth 0 at the first part) by if (prev != null && prev.index > dictionary.compoundMax - 2) return false;, which skips the first part. The code reached 8.x through the backport apache/lucene-solr#2499 (LUCENE-9687, merged 2021-05-28). Since both the feature and the regression predate every release, no release ever enforced a limit of 1.

Versions not executed are covered by reading: the guard line is byte-identical in all 33 release tags from 9.0.0 to 10.5.2, and the bytecode of every 8.x jar with the API (8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4) has the same guard; of these only 8.9.0 and 8.11.4 were run. Commit-level runs compile each commit's hunspell sources over the 9.0.0 jars; they are not full builds. Hunspell.java:214 is unchanged on upstream main a95c754ee003 (2026-10-26) and c5ab3e57b9ba (2026-10-28).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnit fails 3 of 4 through gradle, assertions on and off; the finder's and the reproducer's tests fail with -ea and -da
10.0.0, 10.5.2 affectedthe finder's test fails; this doc's JUnit fails 3 of 4 on 10.5.2
9.0.0, 9.1.0, 9.12.3 main line from 9.0.0the finder's test fails
8.9.0, 8.11.4 first releasethe finder's test (pre-9.1 import) fails
8.10.0, 8.10.1, 8.11.0 to 8.11.3 by bytecodenot run; javap shows the same guard in each jar
8.8.2 no spell-checking APIthe repro does not compile (no Hunspell class)
a176308aa63, 9d45dfe776a, parent 16764f16010 (commit-level) before the changea limit of 1 is enforced (a176308aa63: COMPOUNDBEGIN/END path only; COMPOUNDFLAG support came later)
650f16ad5d8 (commit-level) introducing commitfails on the COMPOUNDFLAG and the COMPOUNDBEGIN/END path
main + recommended fix not affectedthis doc's JUnit passes 4 of 4; 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; independent fact checks re-checked every claim.

Checks and verdicts469 words

Every check reproduced the defect and agreed on the root cause; none found a convincing "intended" argument. The impact was rated minor throughout.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential against C Hunspell 1.7.2, then a 1-test JUnit: fails on main (-ea, -da), 10.5.2 and 9.12.3; C 1.7.2 (two builds) rejects foobar at 1; the fix passes.
cross-model review, refute lens (OpenAI GPT-5.6) real, high; intended argument weakReproduced with its own words, -da too; the max-2 control passes; the fix passes, and TestSpellChecking (53 tests) passes with it; pointed out that LUCENE-9699 enforced 1.
independent reproducer realOwn JUnit (3 claim tests, 5 controls) fails 3 of 8 on main (-ea, -da), 10.5.2, 9.12.3 and 9.0.0; C 1.3.2: 48 comparisons, only the 6 two-word cells at 0 and 1 differ; its own equivalent fix passes 8 of 8.
cross-model review, intended lens (OpenAI GPT-5.6) real, high; intended argument weakOwn test fails 2 of 2 on main, 10.5.2 and 9.12.3 and passes with the fix; 810 upstream expectations unchanged with the fix; scoped the claim to flag compounding.
intended-behaviour devil's advocate real, impact low70-cell grid against C 1.7.2: 4 mismatches on main, 9.0.0, 8.11.4 and 8.9.0, 0 with the fix; surveyed 477 public .aff files (none uses 0 or 1); widened the affected versions to 8.9.0.
adjudication confirmed, high; impact minorRe-ran the independent reproduction's JUnit (3 of 8 fail with -ea and -da, 8 of 8 pass with the fix) and native C Hunspell 1.3.2.
registry re-run and credibility audit keepRe-ran on a9779ef3c193 the finder's test (fails with -ea and -da; passes with the fix shadowed, -ea) and the independent reproducer's test (fails 3 of 8 with -ea; passes 8 of 8 with the fix shadowed, -ea and -da); re-read the root cause, the C rule and the history.
this doc reproduced; fix validatedNew 4-test JUnit: 3 of 4 fail through gradle (assertions on and off) and through the runner on main and 10.5.2; with the fix 4 of 4 pass and the analysis-common tests and lint checks pass.
independent fact check holds after one count fixRe-ran this doc's gradle command (3 of 4 fail, assertions on and off), the fix (4 of 4; TestSpellChecking 53 and TestDictionary 16 pass), the runner on main, 10.5.2, 9.1.0 and 8.9.0, and C Hunspell 1.7.2 and 1.3.2 on the exact dictionaries; re-read every cited line, the history and upstream main. Found one wrong prior-art split (fixed).
cross-model fact check (OpenAI GPT-5.6) real; brief substantially soundRead-only review against saved sources and logs: bug, root cause, fix and the security verdict (not security-sensitive) hold; its wording corrections, overclaims and caveats (negative values, the narrower 146-test run, internal labels) are resolved.

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

Finding record

LFV-724, snapshot of 2026-11-06

Registry title
Hunspell spell() accepts two-word flag-based compounds (COMPOUNDFLAG or COMPOUNDBEGIN/END) when COMPOUNDWORDMAX is 1 (or 0, which the parser clamps to 1), because the word-count check skips the first compound part
Area
analysis / hunspell (Hunspell.spell compound checking, COMPOUNDWORDMAX)
Priority
Low, rank 468: Only the opt-in Hunspell spell() verdict is wrong, only with a self-contradictory COMPOUNDWORDMAX 0 or 1 that no public dictionary uses, and stemming is unaffected.
Impact class
minor
Impact note
Only applications that call the opt-in Hunspell spell-checker (Hunspell.spell; suggest builds on the same check) with a dictionary that uses flag-based compounding (COMPOUNDFLAG or COMPOUNDBEGIN/MIDDLE/END) and sets COMPOUNDWORDMAX to 0 or 1 (none of the 477 public .aff files with this directive found by a code search does) are hit, and only for two-word compounds: spell() returns true where C Hunspell returns false; limits of 2 or more, and compounds of 3 or more words at a limit of 1, are handled correctly, -ea and -da behave the same, stemming and analysis (HunspellStemFilter) never read the limit, no scale is needed, and every release from 8.9.0 to 10.5.2 has it.
Summary
You load a Hunspell dictionary with 'COMPOUNDFLAG C', 'COMPOUNDMIN 1', 'COMPOUNDWORDMAX 1' and the words foo/C and bar/C, call Hunspell.spell("foobar"), and expect false: hunspell(5) defines COMPOUNDWORDMAX as the maximum word count in a compound, and C Hunspell 1.3.2 and 1.7.2 reject foobar. Instead Lucene returns true, with and without -ea; COMPOUNDWORDMAX 0, which the parser clamps to 1, behaves the same, while with COMPOUNDWORDMAX 2 Lucene correctly accepts foobar and rejects foobarfoo. The cause: checkCompounds counts parts only when a previous part exists ('prev != null && prev.index > compoundMax - 2'), so the first part is never counted and checkCompoundsAfter accepts first part + last part at any limit; the original LUCENE-9699 check, which did enforce a limit of 1, was lost in a 2021 refactoring (LUCENE-9729).
Introduced
650f16ad5d8e, LUCENE-9729 'Hunspell: support CHECKCOMPOUNDREP flags' (apache/lucene-solr#2300, by donnerpeter; issue apache/lucene#10768), in the PR's refactoring sub-commit f78cc2563024 'LUCENE-9729: [Hunspell] refactoring': it replaced 'if (depth > dictionary.compoundMax - 2) return false;' (depth 0 at the first part, so COMPOUNDWORDMAX 1 rejected every compound, as written by LUCENE-9699 a176308aa63, apache/lucene-solr#2248, issue apache/lucene#10738) with 'if (prev != null && prev.index > dictionary.compoundMax - 2) return false;', which skips the first part [read: git show 650f16ad5d8 in the history clone] [exec: the hunspell sources of a176308aa63, 9d45dfe776a and the parent 16764f16010, compiled over the 9.0.0 jars, enforce the limit of 1; those of 650f16ad5d8 fail the repro on both the COMPOUNDFLAG and the COMPOUNDBEGIN/END path]. Reached 8.x through the backport apache/lucene-solr#2499 (LUCENE-9687, merged 2021-05-28). Both the feature and the regression predate every release, so no release ever enforced COMPOUNDWORDMAX 1 and it is not a regression of a released version (2021-02-05 (main line; the sub-commit is dated 2021-02-03); 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: 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) [exec, follow-up]; the independent reproduction and the intended-behaviour check independently executed 8.9.0, 8.11.4, 9.0.0, 9.12.3 and 10.5.2 with their own tests [exec]. The other releases [read]: the guard line is byte-identical in all 33 release tags from 9.0.0 to 10.5.2, and the 8.9.0 bytecode (javap) has the same guard. Not affected: 8.8.2 and older (no spell-checking API) [exec 8.8.2]. Not fixed upstream: Hunspell.java:214 is unchanged on upstream main a95c754ee003 (2026-10-26) and no open PR changes it [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, completed, read-only, the prior-report record verdict new, 129 query records: scripted prior-report searches A and B (110 records, 57 and 53, over GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; run B's 6 web queries only planned) plus 19 manual records (14 GitHub and git-history, 5 web), among them a listing of all 410 open apache/lucene PRs and the file lists of the 16 analysis PRs; the earlier independent reproduction (9 queries) and the intended-behaviour check (43 queries) scripted prior-report searches agree; 15 hits judged, none the same bug). repo:apache/lucene COMPOUNDWORDMAX gives 0 results; COMPOUNDWORDMAX on dev@, java-user@ and issues@ gives 0 mails (commits@: 9 commit-diff mails only); JIRA COMPOUNDWORDMAX and compoundMax give 0. Closest items: #10768 (LUCENE-9729, apache/lucene-solr#2300, 650f16ad5d8), the introducing change, whose only review comment is about CheckCompoundPattern offsets; #10738 (LUCENE-9699, apache/lucene-solr#2248), which added COMPOUNDWORDMAX with a check that did enforce 1; #10726 (LUCENE-9687, the umbrella issue, and its 8.x backport apache/lucene-solr#2499); open PRs #16422 (stale Dictionary.java parse refactor that keeps the clamp) and #16911 (branch_10x decommission), neither a fix. Not fixed upstream: Hunspell.java:214 unchanged on upstream main a95c754ee003 (2026-10-26).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit through gradle in a clean checkout with the test copied in, stock and with the recommended fix, assertions on and off, plus the whole lucene/analysis/common test task and its lint checks with the fix; through the hunt's classpath runner the same JUnit on main (-ea, -da), with the fix shadowed, and on the 10.5.2 release jars (2026-10-28); independent fact checks re-ran the gradle repro, the fix and C Hunspell. Hunt runs on main a9779ef3c193 and ea57360912ed (identical hunspell package), on 7 releases from 8.9.0 to 10.5.2 and at 4 commits around the change; C Hunspell 1.3.2 and 1.7.2 on the same dictionaries.

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 ('whether the given word's spelling is considered correct according to Hunspell rules'), :213-214 checkCompounds count check ('prev != null && prev.index > dictionary.compoundMax - 2'), :269-286 checkCompoundsAfter (last part accepted without a count check), :372 CompoundPart index (1 for the first part)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java at a9779ef3c193: :152 compoundMax default Integer.MAX_VALUE, :439 'COMPOUNDWORDMAX' parsed as Math.max(1, n); grep: compoundMax is read only at Hunspell.java:214
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (lines 150-161, 213-247, 269-286, 372)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java (lines 152, 439)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (line 300); grep: in production code compoundMax is read only at line 214 (the test TestDictionary.java:184 asserts the parsed value)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java (lines 94-103); lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilter.java (lines 108-114)
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/TestHunspellRepositoryTestCases.java (lines 38-46); lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestDictionary.java (lines 180-184); lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/forgivable-errors.aff (line 4)
Diagrams
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java (line 214)

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

Repro files

About the diagrams

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.