All findings · Lucene finding · LFV-1012 · hunspell-undefined-morph-alias-aioobe

Hunspell: a .dic entry with an undefined AM morphological alias throws ArrayIndexOutOfBoundsException instead of a descriptive error

When a hunspell .dic entry uses a morphological alias number missing from the .aff file's AM table, loading fails with a raw ArrayIndexOutOfBoundsException (or NullPointerException) that names neither the alias nor the input line. The same mistake in the flag-alias (AF) table gets a clear Bad flag alias number:2.

Summary

Priority
Low, rank 1062A wrong-error case (internal AIOOBE/NPE instead of a parse error) in the opt-in Hunspell module that needs a malformed .dic/.aff pair.
Impact
wrong error
Since
4.10.0 (LUCENE-5778, commit 532d9f246b8, 2014-06-21)introduced 2014-06-21 · over 12 years
Affected
4.10.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading, then an executed probe and JUnit test

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

Background: alias tables in hunspell dictionaries

  1. A hunspell dictionary is two files: the .aff file with rules, and the .dic file with one word per line, optionally followed by flags (work/A) and, after a tab, morphological fields such as po:noun (part of speech).
  2. To make the files smaller, the .aff can number repeated values in alias tables: AF for flag sets and AM for morphological fields. AM 2 declares two entries; the next two lines, for example AM po:noun and AM po:verb, are aliases 1 and 2.
  3. A .dic entry then writes only the number: work/A<TAB>2 means flag A and the morphological field of alias 2. Lucene's own test dictionary morphalias works this way (AM 4, entries using aliases 1 to 4).
Diagram of the bug: the affix file's AM table declares 1 alias (alias 1 = po:noun) and the dictionary entry foo uses alias 2. Lucene looks up morphAliases[2 - 1] in a 1-slot array with no range check and throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1. The same mistake in the AF flag-alias table gives IllegalArgumentException: Bad flag alias number:2. The fix checks the alias against 1 and the number of AM entries defined.
An AM table with one entry and a .dic entry asking for alias 2: the lookup reads slot 1 of a 1-slot array and throws, while the same mistake in the AF table gets a named error. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Load a hunspell dictionary whose .aff has a one-entry morphological alias table, AM 1 then AM po:noun, and whose .dic has the entry foo<TAB>2: the word foo with alias 2, which the table does not define.
  2. You expect a descriptive error, as for the same mistake in the flag-alias table (AF 1, AF A and the entry foo/2): IllegalArgumentException: Bad flag alias number:2. C Hunspell's source range-checks the index and keeps the word without morphological data (its bad morph. alias index warning prints only in builds with warnings switched on).
  3. Instead Lucene looks up morphAliases[2 - 1] in a 1-slot array with no range check: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1, and the error does not say which alias or input line is wrong.
  4. Alias 0 gives Index -1 out of bounds for length 1; AM 2 with only one AM line gives NullPointerException ... because "morphData" is null. HunspellStemFilterFactory wraps only ParseException, so its users get the raw exception too. Every release since 4.10.0 (2014) has it.

Who is affected

Applications that load hunspell dictionaries with AM morphological aliases, through any Dictionary constructor or HunspellStemFilterFactory, when a .dic entry points at an undefined alias: a hand edit, an AM table with fewer lines than its count, or a .dic paired with another version's .aff. Rare; the load fails either way.

Mechanism, terms and numbers486 words

Hunspell affix files can number their morphological descriptions in an AM table ("alias compression"), and .dic entries then carry only the number. When an entry uses a number the table does not define, Dictionary.splitMorphData reads morphAliases[alias - 1] with no range check and catches only NumberFormatException. So AM 1 / AM po:noun plus the entry foo<TAB>2 makes every Dictionary constructor throw ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1; alias 0 gives Index -1, and an AM table with fewer lines than its count gives a NullPointerException. The flag-alias lookup a few lines above turns the same out-of-range lookup into IllegalArgumentException: Bad flag alias number:N, and has since 2012 (LUCENE-4079); C Hunspell's source range-checks the index. The unchecked lookup came with AM support itself (LUCENE-5778, 4.10.0) and is in every release since, including 10.6.0 and main. The malformed dictionary is refused either way: there are no wrong results, only an unhelpful error. A one-line range check mirroring the AF guard fixes it.

Terms

  • AM table (morphological aliases): a header AM <count> and then one AM <fields> line per alias, numbered from 1. hunspell(5): "Hunspell can also substitute morphological data with ordinal numbers in affix rules (alias compression)".
  • AF table (flag aliases): the same idea for flag sets; foo/2 means the flags of AF alias 2.
  • morphAliasCount: how many AM entries the .aff actually defined.

The bug

  1. parseMorphAlias (Dictionary.java:1295-1304 at 645d4fe14a20) allocates morphAliases = new String[count] from the declared count and fills one slot per AM line, so slots beyond the lines actually given stay null.
  2. splitMorphData (:1306-1315) parses a numeric morphological field and does morphData = morphAliases[alias - 1] (:1311) inside a try that catches only NumberFormatException (meant for non-numeric, literal fields). There is no check of alias against 1 and morphAliasCount.
  3. An alias above the declared count, or 0 or below, throws ArrayIndexOutOfBoundsException; an alias in a declared but undefined slot yields null and a NullPointerException at morphData.isBlank() (:1315).
  4. Both loading passes reach it (mergeDictionaries :1030 while probing for morphological data, and readMorphFields :1142/:1207 for every entry with morphological fields while the word table is built), with either sorting strategy, and the exception leaves the constructor.
  5. The flag-alias sibling getAliasValue (:1287-1293) catches the IndexOutOfBoundsException and throws IllegalArgumentException("Bad flag alias number:" + id, ex).

The numbers

.aff SET UTF-8, AM 1, AM po:noun; .dic 1, foo<TAB>2: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 at Dictionary.splitMorphData(Dictionary.java:1311), from mergeDictionaries(:1030) and the constructor (:268). foo<TAB>0: Index -1 out of bounds for length 1; foo<TAB>-1: Index -2. AM 2 with only AM po:noun, entry foo<TAB>2: NullPointerException: Cannot invoke "String.isBlank()" because "morphData" is null. Control: AF 1, AF A, entry foo/2: IllegalArgumentException: Bad flag alias number:2.

Exposure and likelihood262 words
  • Who is hit. Users of the opt-in hunspell analysis component whose dictionaries use AM aliases, through new Dictionary(...) (all constructors, both sorting strategies) or HunspellStemFilterFactory (SPI name hunspellStem, for example in CustomAnalyzer).
  • What it takes. One .dic entry whose morphological field is a number the AM table does not define. No upstream test dictionary (Lucene's morphalias; C Hunspell's alias, alias2 and alias3 tests) or report shows one, and public dictionaries were not surveyed, so it is rated rare.
  • What happens. One bad word entry fails the whole load, with an exception that does not say which alias or input line is wrong: "Index 1 out of bounds for length 1" means "alias 2 is not in your AM table". HunspellStemFilterFactory.inform wraps only ParseException into its Unable to load hunspell data! [dictionary=...,affix=...] IOException (HunspellStemFilterFactory.java:115-118), so factory users do not even get the file names.
  • What does not happen. No wrong stems, no silent acceptance, no hang, nothing written. Correct dictionaries, dictionaries without an AM table, and dictionaries whose AM header has no AM lines (the lookup is skipped while morphAliasCount is 0, Dictionary.java:1308) do not hit it.
  • Related, separate fix sites (not part of this bug). An AF table that declares more aliases than it defines gives NullPointerException ... "rawFlags" is null; AF/AM tables with more lines than their count, or a missing or non-numeric count, throw raw exceptions while the .aff is parsed. With the offline sorting strategy this exception is also one trigger of LFV-1006 (temp output not released on failure).

Minimal repro

One JUnit test on the public API: a one-entry AM table and a one-word .dic loaded through new Dictionary(aff, List.of(dic), false, SortingStrategy.inMemory()). It accepts a successful load, a ParseException or an IllegalArgumentException; it turns an IndexOutOfBoundsException into a clear failure, and any other unexpected exception also fails it. Deterministic, single thread, about 0.3 s.

TestDictionaryUndefinedMorphAlias.java (JUnit, one test)46 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.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDictionaryUndefinedMorphAlias extends LuceneTestCase {

  private static InputStream stream(String s) {
    return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
  }

  public void testMorphAliasBeyondAmTable() throws Exception {
    InputStream aff = stream("SET UTF-8\nAM 1\nAM po:noun\n");
    InputStream dic = stream("1\nfoo\t2\n");
    try {
      new Dictionary(aff, List.of(dic), false, SortingStrategy.inMemory());
    } catch (ParseException | IllegalArgumentException descriptive) {
      // loading foo or a descriptive error (as AF gives "Bad flag alias number") is fine
    } catch (IndexOutOfBoundsException e) {
      throw new AssertionError(
          "expected a descriptive error for undefined AM alias 2 (the table has 1 entry); actual: "
              + e,
          e);
    }
  }
}
Command
cp TestDictionaryUndefinedMorphAlias.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
The load fails with a descriptive error such as Bad flag alias number:2 for the AF table, or foo loads (C Hunspell).
Observed
ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 from Dictionary.splitMorphData; the same with assertions off.

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

More files, environment, full output279 words

The oracle does not pin the fix's exception type: loading foo (what C Hunspell's source does), a ParseException (what the constructors document) or an IllegalArgumentException (what the AF table gives) all pass. It needs SortingStrategy, so it compiles on 9.9.0 and later; older releases were checked with a release-agnostic probe that calls the public constructor of each version.

As the regression test: the same test, for example as a new method in TestDictionary, with the unused catch parameter descriptive renamed to _ (Lucene's ecjLintTest rejects unused exception parameters; checked with gradle). With the recommended fix it can be tightened to expectThrows(IllegalArgumentException.class, ...). It pins the beyond-the-table case only; that the fix also covers alias 0 and the NullPointerException case was shown by probes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0 and 9.9.0; the same lookup fails on every release from 4.10.0; 4.9.x not affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestDictionaryUndefinedMorphAlias.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestDictionaryUndefinedMorphAlias > testMorphAliasBeyondAmTable FAILED
    java.lang.AssertionError: expected a descriptive error for undefined AM alias 2 (the table has 1 entry); actual: java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:36D1E7B404813866]:0)
        at org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias.testMorphAliasBeyondAmTable(TestDictionaryUndefinedMorphAlias.java:40)
        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
            at org.apache.lucene.analysis.hunspell.Dictionary.splitMorphData(Dictionary.java:1311)
            at org.apache.lucene.analysis.hunspell.Dictionary.mergeDictionaries(Dictionary.java:1030)
            at org.apache.lucene.analysis.hunspell.Dictionary.<init>(Dictionary.java:268)
            at org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias.testMorphAliasBeyondAmTable(TestDictionaryUndefinedMorphAlias.java:36)

# with the recommended range check: the test passes (assertions on and off)

# release jars 10.6.0 (assertions on and off) and 9.9.0 (off): same failure, lookup at Dictionary.java:1323 and :1278

Why it looks unintended

  • The same lookup for flag aliases has thrown Bad flag alias number since 2012 (LUCENE-4079); AM support came in 2014 without the guard.
  • The constructors document ParseException for malformed content; nothing presents an index exception as the signal.
  • In 2012 the hunspell maintainer asked for such errors to be fixed "at their source, to provide targeted Exceptions" (LUCENE-3976).
The full argument246 words
  • Sibling guard. getAliasValue (Dictionary.java:1287-1293) wraps the out-of-range AF lookup into IllegalArgumentException("Bad flag alias number:" + id, ex); it came with AF support in LUCENE-4079 (b4fe59532ee, 2012-05-30) and LUCENE-5468 carried it into the current class. LUCENE-5778 (532d9f246b8, 2014-06-21) added AM with morphAliases[alias-1] and a NumberFormatException catch commented // fine; its issue never discusses undefined aliases.
  • Contract. @throws ParseException Can be thrown if the content of the files does not meet expected formats (:187, :204, :224). No javadoc, test, MIGRATE.md or CHANGES.txt entry mentions an index exception for malformed content.
  • Maintainer intent. LUCENE-3976 (#5049), Chris Male, 2012-06-01: "I think we should explore what those possible errors are and fix them at their source, to provide targeted Exceptions. If there is a problem parsing, then we should thrown a ParseException with the line number causing the problem", then "We will tackle error messages on a per-error basis". Later work went the same way: #12468 (stricter .aff checks with ParseException) and #16919 (merged 2026-10-28: it replaced an AssertionError for a malformed affix condition with an IllegalStateException and stopped catching Throwable in AffixCondition).
  • Tests. TestDictionary.testInvalidData expects ParseException for malformed affix rules; no hunspell test uses an undefined AM alias, and none expects an index exception.
  • Reference implementation. C Hunspell's HashMgr::get_aliasm checks index > 0 && index <= aliasm.size() and otherwise returns null; its error: bad morph. alias index: %d warning prints only in builds with HUNSPELL_WARNING_ON.
The devil's-advocate check166 words

The strongest case for "intended" (an intended-behaviour checker, and a read-only reviewer from another model family, OpenAI GPT-5.6, which leaned "not real"): the input is a malformed, trusted configuration file and is refused loudly either way, with no wrong results; "can be thrown" is permissive, not a promise that every malformed file gives ParseException; the parser already lets several unchecked exceptions escape for nearby malformed content (the AM/AF header parsers); and upstream files such changes as improvements (LUCENE-3976 was an enhancement, #12468 is listed under Improvements).

Why it fails: it limits severity, not validity. Nothing presents an index exception as intended; the same mistake one table over is deliberately turned into a named error; and the maintainer's stated policy was to fix such errors at their source with targeted exceptions. The other raw exceptions are separate, equally unpolished sites, not evidence of a design. Hence "wrong error" at low priority: the case is for a descriptive error, not a crash on valid input.

Suggested fix

Recommended A. Range-check the alias and throw a named error (mirrors getAliasValue)

In splitMorphData, check the alias against 1 and the number of AM entries defined, and throw IllegalArgumentException("Bad morphological alias number:" + alias), as the AF guard does. This also covers alias 0 and the NullPointerException case. With it the repro passes with assertions on and off; the hunspell package runs 159 tests with 0 failures.

         int alias = Integer.parseInt(morphData.trim());
+        if (alias < 1 || alias > morphAliasCount) {
+          throw new IllegalArgumentException("Bad morphological alias number:" + alias);
+        }
         morphData = morphAliases[alias - 1];
All options and trade-offs3 options · 528 words

Two options were run through gradle in a scratch worktree of 645d4fe14a20; A is recommended.

A. Range-check the alias and throw a named error (mirrors getAliasValue) (validated by the checks) (recommended)

In Dictionary.splitMorphData (Dictionary.java:1310-1311).

         int alias = Integer.parseInt(morphData.trim());
+        if (alias < 1 || alias > morphAliasCount) {
+          throw new IllegalArgumentException("Bad morphological alias number:" + alias);
+        }
         morphData = morphAliases[alias - 1];

Trade-offs

  • The load still fails, now with the same exception type and wording as the AF case.
  • Comparing with morphAliasCount (entries defined), not the array length, also covers alias 0, negative aliases and the NullPointerException case.
  • No line number: splitMorphData has no reader; a ParseException with a line would need the callers to track lines and rethrow.
  • IllegalArgumentException is not a ParseException, so HunspellStemFilterFactory still adds no file names, as for AF today. No signature or file-format change, but the exception callers see for this malformed input changes; one comparison per numeric morphological field at load time.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • Closest suites: every test class of the hunspell package (TestDictionary, TestMorphAlias, TestMorphData, TestMorph, TestHunspellStemFilterFactory, TestSpellChecking, TestStemmer, ...): 47 classes with the repro, 159 tests, 0 failures, 13 skipped (three classes that need external dictionaries or corpora, or are @Ignored).
  • ./gradlew :lucene:analysis:common:check -x test passes with the fix and the test in place (google-java-format, ecj lint, forbidden APIs, checksum checks) once the test's unused catch parameter descriptive is renamed to _; with the original name ecjLintTest rejects it. The renamed test still fails on stock and passes with the fix. The full test suite was not run.
  • Earlier, shadowed into the stock main jars: the finder's 5-test JUnit passes 5/5 with 79 upstream hunspell tests, the reproducer's 4-test JUnit 4/4, and the minimal test with assertions on and off.

B. Tolerate it like C Hunspell: an undefined alias means no morphological data (validated by the checks)

The same check, but return Collections.emptyList(); instead of the throw.

         int alias = Integer.parseInt(morphData.trim());
+        if (alias < 1 || alias > morphAliasCount) {
+          // like C Hunspell, which warns "bad morph. alias index" and keeps the word
+          return Collections.emptyList();
+        }
         morphData = morphAliases[alias - 1];

Trade-offs

The dictionary loads and foo has no morphological data, as in C Hunspell's source. But the mistake goes unnoticed: Dictionary has no warning channel, and C Hunspell's own warning prints only in builds with HUNSPELL_WARNING_ON; and it departs from Lucene's choice for the same mistake in AF, which fails.

What the checks validated

Gradle in the same scratch worktree: the repro passes with assertions on, and the hunspell package runs without failures (47 classes, 159 tests, 0 failures, the same 13 skips). Assertions off and the static checks were not run for B.

Catch IndexOutOfBoundsException around the lookup (not recommended)

The getAliasValue pattern.

Trade-offs

Fixes the ArrayIndexOutOfBoundsException cases but not the NullPointerException one: declared but undefined slots are inside the array and hold null. Not tried.

Downstream note

Products that load user-supplied hunspell dictionaries and catch only ParseException or IOException around the load, as HunspellStemFilterFactory does, will see this ArrayIndexOutOfBoundsException escape instead.

How it was found

Non-formal: while listing which malformed-content exceptions can interrupt hunspell dictionary loading (for a resource-release model of a sibling bug), an AI tool reading splitMorphData saw the unchecked lookup next to the guarded AF one. A probe of seven alias-table cases and a public-API JUnit test then reproduced it.

The method in full117 words

Method: non-formal (code reading, then executed tests). On 2026-10-29 the hunt unit for analysis loaders was modelling, in TLA+, whether the hunspell loader releases its temporary sort output when loading fails (the sibling LFV-1006). To list the content-driven exceptions that can make the dictionary-merge step throw, it read the parsing code and noticed that splitMorphData reads morphAliases[alias - 1] unchecked while getAliasValue guards the same lookup. The TLA+ model has no alias logic; the defect was read off the code. A probe (seven alias-table cases, including the NullPointerException variant and the AF neighbours) and a 5-test public-API JUnit confirmed it; independent checkers then reproduced it with their own tests, and it was cut to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (incl. the JIRA archive), all 415 open PRs, the Lucene mailing lists, downstream trackers or the web (2026-10-29); LUCENE-3976 (2012) is related context only. Not fixed upstream as of 2026-11-01.

Searches4 searches

Closest items, none a report of this bug: #6840 (LUCENE-5778, the introducing feature); #5151 (LUCENE-4079, added AF aliases and the Bad flag alias number guard); #5049 (LUCENE-3976, 2012, Won't Fix: a generic request for better hunspell error messages, triggered at another site two years before AM aliases existed, closed so errors get fixed one at a time); SOLR-2934 (a 2012 comment that such exceptions on some dictionaries are hard to debug, old class, other site); #10772 and #6570 (alias load errors on valid input, fixed); #12468, #14239, #10732 (validation of other tables); #16919 (AffixCondition only); open PR #16422 (directive parsing, alias code unchanged). Related finding: LFV-345 (truncated REP/MAP/ICONV tables give NullPointerException), same class, different fix site. Limits: GitHub search does not index code or diffs; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits2026-10-29two scripted prior-report searches (36 issue/PR and 6 commit queries) plus 29 manual issue/PR and 3 commit queries: aliases, morphological, AM, NPE/AIOOBE, ParseException, malformed, "Bad flag", "error message", HunspellStemFilterFactoryno report; splitMorphData 0 hits
Open PRs2026-10-29changed files of all 415 open PRs and of every PR updated since 2026-10-25only #16422 touches Dictionary.java, alias code unchanged
JIRA archive, mailing lists, downstream trackers, web2026-10-298 scripted and 6 manual JIRA queries (LUCENE, SOLR); 40 queries over dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr and Lucene.NET; 12 scripted and 16 manual web queriescontext only (LUCENE-3976, SOLR-2934)
Upstream main2026-11-01file contents of Dictionary.java at 2f424fd428da (2026-10-30) and its commit liststill morphData = morphAliases[alias - 1];; newest change #16313 (2026-07-21, refactor)

History

Introduced with AM support itself in 4.10.0 by LUCENE-5778 (532d9f246b8, 2014-06-21). Every release from 4.10.0 to 10.6.0 and main fails; 4.9.0 and 4.9.1, which do not parse AM, are not affected.

Versions tested6 rows

Introduced by LUCENE-5778 "Support hunspell morphological description fields" (#6840, by Robert Muir, pre-GitHub, no PR): trunk commit 532d9f246b8 and branch_4x backport b937174858c, both 2014-06-21; git tag --contains b937174858c starts at 4.10.0. Later commits to these lines (a comment typo, a reformat, the rename parseStemException -> splitMorphData in LUCENE-9734, catch-variable renames) change no behaviour. The NullPointerException message follows the code shape: String.indexOf (4.10.0-8.8.2), String.trim (8.9.0-8.11.4), String.isBlank (9.0.0-main).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); passes with fix A or B
10.6.0 affectedminimal repro fails with assertions on and off (release jars)
branch_10_6 4ae946e5a9b affectedrelease-agnostic probe fails
9.9.0, 9.12.3, 10.5.2 affectedthe finder's 5-test JUnit fails 3 of 5; the minimal repro fails on 9.9.0
4.10.0 to 10.5.2 (112 releases) first affected 4.10.0release-agnostic probe fails on every release (ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1)
4.9.0, 4.9.1 no AM supportthe same input loads; the AF control already gives Bad flag alias number:2

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, an intended-behaviour devil's advocate and an adjudication all reproduced it. A reviewer from another model family, unable to run code, leaned "not real" on the contract but agreed with the root cause.

Checks and verdicts251 words

Everyone agrees on the root cause and that it is a wrong error only: the dictionary is refused either way.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate5-test JUnit fails 3 of 5 on main with assertions on and off; both controls pass; the probe adds the NullPointerException case; the fix passes 5/5 and 79 upstream hunspell tests.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, mediumCould not run code; argued malformed data is outside the contract and the javadoc promises no ParseException. Agreed on the root cause and called the missing guard accidental. A separate re-run: 3 of 5 fail.
independent reproducer realIts own JUnit, written from the claim, fails 2 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; controls pass; 4/4 with the fix.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumThe works-as-designed case is moderate but does not hold: the AF sibling wraps the same condition.
intended-behaviour devil's advocate real, minorBuilt the strongest intended case and rejected it (LUCENE-4079 guard, LUCENE-3976 maintainer statement); main and 10.5.2 fail 3 of 5.
adjudication confirmed, highRe-ran the reproducer's JUnit with assertions on and off: 2 of 4 fail in each. It classed it as a missing range check with an unhelpful error, low priority.
Registration check and credibility audit confirmedRe-runs on main fail 3 of 5 with assertions on and off; 5/5 with the fix shadowed.

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

Finding record

LFV-1012, snapshot of 2026-11-06

Registry title
Hunspell Dictionary throws a raw ArrayIndexOutOfBoundsException (or NullPointerException) instead of a descriptive error when a .dic entry uses an AM morphological alias number that the .aff file does not define, although the same mistake in the AF flag-alias table gets 'Bad flag alias number'
Area
analysis-common / hunspell Dictionary parsing (AM morphological aliases)
Priority
Low, rank 1062: A wrong-error case (internal AIOOBE/NPE instead of a parse error) in the opt-in Hunspell module that needs a malformed .dic/.aff pair.
Impact class
wrong error
Impact note
Hits applications that load hunspell dictionaries with AM morphological aliases (compressed dictionaries) through any Dictionary constructor or HunspellStemFilterFactory (CustomAnalyzer 'hunspellStem'), and only when the .dic refers to an alias number that the .aff's AM table does not define (a hand edit, an AM table with fewer lines than its count, or a .dic paired with a different .aff); the malformed dictionary is refused either way, but with an internal ArrayIndexOutOfBoundsException or NullPointerException that names neither the alias nor the input line (the factory wraps only ParseException, so its users get the raw exception too); there are no wrong results and nothing is corrupted.
Summary
You load a hunspell dictionary whose affix file has an AM table, for example 'AM 1' and 'AM po:noun', and whose .dic has an entry 'foo<TAB>2' that points at an alias the table does not define (or alias 0, or 'AM 2' with only one AM line). You expect a descriptive error, as for the same mistake in the flag-alias (AF) table, which gives IllegalArgumentException 'Bad flag alias number:2' (C Hunspell's source, the reference implementation, range-checks the index and keeps the word without morphological data; its 'bad morph. alias index' warning prints only in builds with HUNSPELL_WARNING_ON). Instead every Dictionary constructor fails with java.lang.ArrayIndexOutOfBoundsException 'Index 1 out of bounds for length 1' (or NullPointerException 'Cannot invoke "String.isBlank()" because "morphData" is null') from Dictionary.splitMorphData, which reads morphAliases[alias - 1] with no range check, and has done so since AM aliases were added in 4.10.0 (LUCENE-5778).
Introduced
532d9f246b829d69b548b5716c591b925ed47ca3 (trunk; LUCENE-5778 'Support hunspell morphological description fields', migrated as apache/lucene#6840, by Robert Muir, svn r1604354, no PR: pre-GitHub), backported to branch_4x as b937174858c7978da798e86089b36c50cdb820f0 (r1604355). It added parseMorphAlias (array sized by the declared AM count) and the alias lookup 'morphData = morphAliases[alias-1]' inside a try that catches only NumberFormatException; it did not copy the range guard that the AF lookup (getAliasValue) has had since LUCENE-4079 (b4fe59532ee, 2012-05-30). No later commit added a range or null check: 2189b7a761d (LUCENE-6134, comment), 8ef6a0da568 (LUCENE-9570, reformat), 1852d7ad5ae (LUCENE-9734 / #2308, renamed parseStemException to splitMorphData, lookup copied unchanged), 2971f311a2b (#70) and ad24abfb0df (#15109) change no behaviour [read: git log -S 'morphAliases[', -G 'morphAliases\[alias', -L on splitMorphData and parseMorphAlias, blame of Dictionary.java:1306-1314 on 645d4fe] [exec: release probe passes on 4.9.0 and 4.9.1, which have no AM support, and fails from 4.10.0]. Bisection is at release granularity; the introducing commit is the feature commit itself. (2014-06-21 (532d9f246b8 on trunk 13:15:57 UTC and b937174858c on branch_4x 13:19:02 UTC; LUCENE-5778 resolved the same day))
First affected version
4.10.0 [exec: the release probe fails on 4.10.0 with -ea and -da and passes on 4.9.0 and 4.9.1, which do not parse AM; git tag --contains b937174858c starts at 4.10.0]
Affected versions
[exec] every release on Maven Central from 4.10.0 to 10.5.2 (112 releases; release-agnostic main() probe MorphAliasHistoryProbe with -ea, also -da on 4.10.0, 10.5.2 and main), 10.6.0 (4ae946e5a9b: the probe on the branch_10_6 jars; the minimal repro on the SHA-1-checked 10.6.0 release jars, -ea and -da, 2026-11-01) and main 645d4fe14a20 (-ea and -da: the finder, the independent reproducer, the intended-behaviour check, the adjudication and separate re-runs). The finder's 5-test JUnit (needs SortingStrategy, so 9.9.0 or later) fails 3 of 5 on 9.9.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 2 of 4 on main, 10.5.2 and 9.12.3; the finder's main() variant fails on 8.11.2. The NullPointerException variant (AM declares more entries than it defines) fails on every probed version too; only its message changes with the code shape (String.indexOf 4.10.0-8.8.2, String.trim 8.9.0-8.11.4, String.isBlank 9.0.0-main). Not affected: 4.9.0 and 4.9.1 [exec] and older [read], where AM aliases did not exist. Not new in 10.6. Not fixed upstream: main head is still 645d4fe and the newest commit to Dictionary.java on main is #16313 (author date 2026-07-21, a refactor) (checked 2026-10-29). All runs JDK 25 on aarch64 Linux.
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 and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-29, read-only: two scripted prior-report searches with 106 queries (36 GitHub issue/PR, 6 commit, 4 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA, 12 web), 29 manual GitHub issue/PR and 3 commit queries over apache/lucene, downstream searches of Elasticsearch, OpenSearch, Solr and Lucene.NET, manual JQL over the LUCENE and SOLR JIRA projects, the changed files of all 415 open apache/lucene PRs and of the PRs updated since 2026-10-25; the finder's 51-query run and the independent reproduction's, the intended-behaviour check's and the adjudication's searches also found nothing; refresh 2026-10-29: main head still 645d4fe, the only hunspell PR created since 2026-10-27 is #16919 (AffixCondition only), 'hunspell alias' returns only #10772, #5151, #6570 and #6840, 'splitMorphData' 0 hits). Related, not reports of this bug: #6840 (LUCENE-5778, 2014: the introducing feature; undefined aliases never discussed), #5151 (LUCENE-4079, 2012: added AF aliases and the 'Bad flag alias number' guard), #5049 (LUCENE-3976, 2012, Won't Fix: a generic request for better hunspell error messages, triggered by an ArrayIndexOutOfBoundsException at another site and closed so that such errors get fixed one at a time at their source; it predates AM aliases), SOLR-2934 (a 2012 comment that ArrayIndexOutOfBoundsExceptions on some dictionaries are hard to debug; old HunspellDictionary, other site), #10772 (LUCENE-9733) and #6570 (LUCENE-5507: fixed AF-alias load errors on valid input), #12468, #14239, #14079 and #10732 (LUCENE-9693: validation of other tables and of flags), hunspell/hunspell#540 (another project: an unparseable AF alias); the only open PR on Dictionary.java, #16422, changes affix directive parsing only.
Lucene commit tested
main 645d4fe14a20 (the minimal repro with assertions on and off, standalone and through gradle; both fix options in a scratch worktree); SHA-1-checked release jars: the minimal repro on 10.6.0 (assertions on and off) and 9.9.0, the finder's 5-test JUnit on 9.9.0, 9.12.3 and 10.5.2, and a release-agnostic probe on 4.9.0 and 4.9.1 (not affected), all 112 releases from 4.10.0 to 10.5.2 and branch_10_6 4ae946e5a9b. Upstream main 2f424fd428da (2026-10-30) still has the unchecked line (read).

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:186-187, (at 645d4fe14a20) :203-204 and :223-224 (constructor javadoc: '@throws ParseException Can be thrown if the content of the files does not meet expected formats'), :1287-1293 (getAliasValue: IndexOutOfBoundsException -> IllegalArgumentException 'Bad flag alias number:'), :1295-1304 (parseMorphAlias: array sized by the declared AM count, undefined slots stay null), :1306-1315 (splitMorphData: 'morphData = morphAliases[alias - 1]' with only NumberFormatException caught, then morphData.isBlank()), :1030 (call from mergeDictionaries) and :1207 (call from readMorphFields)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:102-122 (at 645d4fe14a20) (inform: new Dictionary(tempDir, "hunspell", affix, dictionaries, ignoreCase); only ParseException is wrapped into IOException 'Unable to load hunspell data!')
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:123-124, :360-361, :1026-1030, :1142, :1205-1207, :1287-1293, :1295-1304, :1306-1315, :268 (at 645d4fe14a20)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/morphalias.aff, morphalias.dic (at 645d4fe14a20)
  • github.com/hunspell/hunspell man/hunspell.5 (AF and AM entries; read-only GET 2026-11-01)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:186-224 (constructors), :1306-1315 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:58, :96-123 (inform; catch ParseException only at :115-118) (at 645d4fe14a20)
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:187, :204, :224, :1287-1293 (at 645d4fe14a20)
  • https://github.com/apache/lucene/issues/5049 (LUCENE-3976 comments of 2012-06-01 by Chris Male; read-only GET 2026-11-01)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestDictionary.java:146-164 (testInvalidData) (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/AffixCondition.java:126, :147 (at 645d4fe14a20); git show c5ab3e57b9b (#16919)
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:1015 (mergeDictionaries reads through a BufferedReader) (at 645d4fe14a20)
Prior reports
  • GitHub API read-only GETs 2026-11-01: repos/apache/lucene/commits/main; contents of Dictionary.java at main; commits?path=Dictionary.java

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.