An affix condition such as [a-c] is split in two: the part that covers the letters the rule strips is checked once at load with Java regex without escaping the dash, and the rest is checked against the stem after the dash is escaped. Java reads a-c as a range; Hunspell reads three characters.
ab, the rules with [a-c] and [^a-c], and how Hunspell and Lucene's strip check read the bracket. Select the still to open it at full size. Generated with AI, checked against the code.- Load a dictionary with the root
aband two suffix rules that replace its finalb:SFX A b x [a-c](givesax) andSFX A b y [^a-c](givesay). Each condition looks at the root's last letter, thebbeing replaced. - In Hunspell a dash inside
[ ]has no special meaning:[a-c]is the three characters a, - and c, andbis not one of them. You expectayto be a word andaxnot; C Hunspell 1.3.2 and 1.7.2 agree. - You get the opposite, with no error:
spell("ax")is true andspell("ay")false, andHunspellStemFilterstemsaxtoaband leavesayalone. Prefix rules onbaflip the same way (xa,ya). - Why: Lucene decides the part of a condition that covers stripped letters once, at load, with Java's
String.matches, which readsa-cas a, b, c. The part checked against the stem is escaped first and read correctly. A reversed[c-a]makes Java throw, and the rule is silently dropped. - Opt-in Hunspell only, since 8.9.0 (LUCENE-9806, a speed-up); 8.8.2 was right. None of 191 public dictionaries has such a rule. The fix passes the three strip checks through the existing
escapeDash.
Only applications that use Lucene's opt-in Hunspell support (Hunspell.spell, or stemming with HunspellStemFilter) with a dictionary that has a dash between two characters inside [ ] over a rule's strip. None of 191 public dictionaries does. A word whose only derivation uses such a rule gets the wrong verdict and stem, silently. Releases 8.9.0 to 10.6.0 (10.6.0 by reading).
Mechanism, terms and numbers797 words
hunspell(5) says that inside the braces of an affix condition "Dash hasn't got special meaning", so [a-c] is the set {a, -, c}. Lucene's AffixCondition.compile splits a bracket condition into the part that overlaps the rule's strip and the part checked against the stem. It decides the strip part once, at dictionary load, with String.matches (java.util.regex) on the condition text without dash escaping (AffixCondition.java:108, :113, :120), while only the stem part goes through escapeDash. Java reads [a-c] as the range a..c, so such a rule is applied where Hunspell skips it, or dropped where Hunspell applies it; a reversed pseudo-range such as [c-a] makes Java throw, and the rule is silently turned into "never applies". With SFX A b y [^a-c] on the root ab, Hunspell.spell("ay") is false and HunspellStemFilter leaves ay unstemmed; C Hunspell accepts ay with the stem ab. It came with LUCENE-9806, a speed-only change, and fails on every release run from 8.9.0 to 10.5.2 and on main (the 9.x and 10.x releases in between have the same code); no surveyed public dictionary has an affected rule. The fix is three escapeDash(...) calls, evaluated only at load.
- Affix rule: a
.affline such asSFX A b y [^a-c]: for roots with flagA, stripbfrom the end, addy, if the condition[^a-c]holds. A prefix rule (PFX) works at the start of the word. - Condition: a simplified pattern matched against the end (SFX) or start (PFX) of the root. hunspell(5): "Dot signs an arbitrary character. Characters in braces sign an arbitrary character from the character subset. Dash hasn't got special meaning, but circumflex (^) next the first brace sets the complementer character set." So
[a-c]is {a, -, c} and[^a-c]is any other character. - Strip part and stem part: the condition's positions that fall on the stripped letters are fixed for the rule, so Lucene decides them once at load; the other positions are checked against each candidate stem.
DictionarycallsAffixCondition.compile(kind, strip, condition, line)once per distinct condition/strip/kind at load (Dictionary.java:749,:760).- For a condition with
[,.or-(AffixCondition.java:153-155),compilecounts its character patterns (:105) and checks the strip part withString.matcheson the text without dash escaping (only an unclosed[is closed first,:98-102): the whole condition when it fits in the strip (:107-108), the leading patterns of a prefix condition (:113), the trailing patterns of a suffix condition (:120). APatternSyntaxExceptionbecomesALWAYS_FALSE(:124-125). - The stem part goes to
regexpCondition, which compilesnew RegExp(escapeDash(condition), ...)(:179); the comment aboveescapeDashreads "\"dash hasn't got special meaning\" (we must escape it)" (:186). - So within one rule, the strip part reads
a-cas the range a..c and the stem part as three characters. Only a dash between two characters is affected: a dash first, last or after^is literal in Java too.
| dictionary rule (root) | word | hunspell(5), C Hunspell | Lucene main | with the fix |
|---|---|---|---|---|
SFX A b x [a-c] (ab) | ax | wrong | correct, stem ab | wrong |
SFX A b y [^a-c] (ab) | ay | correct | wrong, no stem | correct, stem ab |
PFX B b x [a-c] (ba) | xa | wrong | correct | wrong |
PFX B b y [^a-c] (ba) | ya | correct | wrong | correct |
SFX C 0 z [a-c] (empty strip: the stem side) | abz / a-z | wrong / correct | wrong / correct | same |
Worked example, SFX A b y [^a-c]: the condition has 1 pattern and the strip b has 1 letter, so :107 builds [^a-c].* and :108 evaluates "b".matches("[^a-c].*"), false because b is in a..c: the rule becomes ALWAYS_FALSE. With escapeDash the regex is [^a\-c].*, b matches, and the rule becomes ALWAYS_TRUE.
English-looking rules from the independent reproducer (C Hunspell 1.3.2 agrees with every expected value): SFX B y ied [^x-z] on happy makes Lucene reject happied; SFX E a o [z-a] on pizza makes it reject pizzo (Java throws on the reversed range); SFX A y ies [x-z] makes it accept happies and stem it to happy; PFX C y z [x-z] on yes accepts zes; SFX D ey ow [d-f]y on key accepts kow.
Each of the three calls is reached by an executed case: :108 by [a-c], [^a-c] and the reversed [c-a] (SFX A c y [c-a] on ac: ay rejected); :113 by PFX B b y [^a-c]a on baa (yaa rejected); :120 by SFX A b y a[^a-c] on aab (aay rejected); the no-dash control a[^ac] passes.
Exposure and likelihood238 words
- Who. Spell checking (
Hunspell.spell) and stemming (Stemmer,HunspellStemFilter) are affected by execution. Suggestions and word-form generation use the same condition objects, by code reading only. - Opt-in. Hunspell is a separate analysis component with an application-supplied dictionary; no Lucene analyzer uses it by default.
- Effect. Silent and deterministic: each rule is decided once at load, so a word whose only derivation goes through that rule gets the wrong verdict and the wrong stem, with no error (a dictionary entry or another rule for the same word can mask it; the repro dictionaries have none). On a field analysed with
HunspellStemFilterand the repro's dictionary, the querybody:abreturns the documentsabandaxon main andabandaywith the fix. - Real dictionaries. None of 191 public
.afffiles (LibreOffice dictionaries and wooorm/dictionaries, about 2.31 million affix rules) has a rule whose decision changes. An independent survey of the same collections plus the Hunspell 1.7.2 tests, system dictionaries and Lucene's test files found 67 rules with a dash in the strip part, all outside brackets, and 0 changed decisions. None of Lucene's 97 test.afffiles has an unescaped dash between two characters in a bracket condition. - Likelihood: contrived. It takes an unusual hand-written rule; the over-accepting forms (
[a-c]over a stripb) are pairs C Hunspell itself treats as dead rules. No scale is needed: one dictionary line triggers it.
The test shows that when an affix condition overlaps the strip, Hunspell reads the dash in [^a-c] as a regex range, but hunspell(5) says the dash has no special meaning (rule SFX A b y [^a-c], root ab).
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.analysis.hunspell;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestAffixConditionDashInStrip extends LuceneTestCase {
public void testDashInConditionOverStrip() throws Exception {
String aff = "SFX A Y 1\nSFX A b y [^a-c]\n";
String dic = "1\nab/A\n";
Dictionary dictionary =
new Dictionary(
new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
List.of(new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8))),
false,
SortingStrategy.inMemory());
// expected: true ([^a-c] is "not a, - or c" per hunspell(5), so b matches); actual: false
assertEquals("spell(\"ay\")", true, new Hunspell(dictionary).spell("ay"));
}
}
# in a copy of this finding's repro folder (repro/LFV-376/, 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.TestAffixConditionDashInStrip
- Expected vs actual
- Expected:
spell("ay")is true (bis not one ofa,-,c; C Hunspell acceptsay). Actual: false.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: spell("ay") expected:<true> but was:<false>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.apache.lucene.analysis.hunspell.TestAffixConditionDashInStrip.testDashInConditionOverStrip(TestAffixConditionDashInStrip.java:36)- hunspell(5): inside braces "Dash hasn't got special meaning";
Hunspell.spellpromises a verdict "according to Hunspell rules". - The same method escapes the dash for the stem part ("dash hasn't got special meaning (we must escape it)"), so one condition is read two ways.
- A speed-only change dropped the whole-condition escaping; 8.8.2 and C Hunspell give the expected answers.
The full argument280 words
- Contract. hunspell(5): "Characters in braces sign an arbitrary character from the character subset. Dash hasn't got special meaning, but circumflex (^) next the first brace sets the complementer character set."
Hunspell.spellreturns "whether the given word's spelling is considered correct according to Hunspell rules" (Hunspell.java:71-72). The class "can be used in place of native Hunspell for many languages"; its javadoc gives examples of what is not supported yet ("For example:" Hungarian, characters outside the BMP, the PHONE option,:35-44); none is about affix conditions. - Lucene's own intent. The comment above
escapeDash(AffixCondition.java:186) and its use for the stem part (:179). LUCENE-5777 (2014): "Basically '-' doesnt mean anything special (e.g. what it normally means for a regex), so we usually have to escape it." CHANGES 4.9: "LUCENE-5768, LUCENE-5777: Hunspell condition checks containing character classes were buggy." - Tests. No test asserts range semantics.
testConditionHasBracketsIntersectingWithStripuses[io], no dash; the only dash fixture (double-escaped.aff,[^\-]arwith stripar) has the class on the stem side;testImpossibleConditionshows Lucene folds a condition that cannot hold over the strip toALWAYS_FALSE. - History. Before LUCENE-9806,
Dictionaryescaped the dash in the whole condition (since 2014). The commit that split the condition removed that escaping; its PR describes a speed-up only and its review never mentions dashes or ranges. 8.8.2 gives the hunspell(5) answers. - Reference. C Hunspell 1.3.2 (every word the adjudication and the independent reproducer checked) and 1.7.2 (the primary test's dictionary in UTF-8 and ISO8859-1, and the three variants that reach each strip check) give the expected spell verdicts; both give the expected stems (
aytoab, none forax).
The devil's-advocate check179 words
The strongest case (from the works-as-designed review, the devil's advocate and the adjudication): Lucene promises to replace native Hunspell only for many languages, HunspellStemFilter is @lucene.experimental, the strip/stem split is a deliberate optimization, the over-accepting forms are rules C Hunspell calls "incompatible stripping characters and condition", and no public dictionary is affected.
Why it fails: the javadoc's examples of unsupported features say nothing about affix conditions, and spell promises a verdict "according to Hunspell rules"; the same class escapes the dash for the stem part, so one condition text is read two ways in one rule; the change was speed-only and 8.8.2 was correct; Hunspell.spell is not experimental, and experimental does not license wrong results; the under-accepting forms ([^a-c] over the strip, [c-a]) are rules C Hunspell applies with no warning, and Lucene silently drops them; for the over-accepting forms C Hunspell keeps a dead rule, which Lucene itself means to fold to ALWAYS_FALSE. "No public dictionary is affected" is reach, not intent. Every reviewer rated the intended-behaviour case weak. Verdict: not intended, a regression.
Recommended A: escape the dash in the three strip checks (three calls)
Pass the three strip checks through the escapeDash helper the stem part already uses (AffixCondition.java:108, :113, :120); it runs only at load. With it every repro passes, the whole hunspell test package passes with assertions on and off, also together with the sibling short-condition fix, and lint passes.
- return strip.matches(regex) ? ALWAYS_TRUE : ALWAYS_FALSE;
+ return strip.matches(escapeDash(regex)) ? ALWAYS_TRUE : ALWAYS_FALSE;
...
- if (!strip.matches(condition.substring(0, split))) {
+ if (!strip.matches(escapeDash(condition.substring(0, split)))) {
...
- if (!strip.matches(condition.substring(split))) {
+ if (!strip.matches(escapeDash(condition.substring(split)))) {All options and trade-offs3 options · 559 words
One fix was built and run in a scratch worktree at a9779ef3c193, with a one-method regression test in TestAffixCondition. The sibling bug in the same lines (a condition shorter than the strip checked against the wrong end of the strip) needs its own one-line fix at :107; the two were also run together, and the patches and their tests sit next to each other, so one PR for both is simpler.
Wrap the arguments of the three strip.matches calls in escapeDash(...), so the strip part and the stem part both treat the dash as a plain character (they still use different regex engines: java.util.regex for the strip, Lucene RegExp for the stem). escapeDash keeps an already escaped \- (pt_PT style) and returns its input unchanged when there is no dash.
- return strip.matches(regex) ? ALWAYS_TRUE : ALWAYS_FALSE;
+ return strip.matches(escapeDash(regex)) ? ALWAYS_TRUE : ALWAYS_FALSE;
...
- if (!strip.matches(condition.substring(0, split))) {
+ if (!strip.matches(escapeDash(condition.substring(0, split)))) {
...
- if (!strip.matches(condition.substring(split))) {
+ if (!strip.matches(escapeDash(condition.substring(split)))) {Trade-offs
Only dictionaries with a dash between two characters inside [ ] over the strip change verdicts, toward C Hunspell's; escaped dashes, a dash at a bracket's edge and dash-free conditions give the same answers. No per-word cost: the strip part is still folded to a constant at load; the fix only adds a little string work while the dictionary loads. A reversed pseudo-range such as [c-a] no longer throws, so such a rule becomes live as hunspell(5) defines it.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: stock plus the regression test and the three repro classes fails 13 of 24 (13 of 25 with the line probe, which passes; controls pass), with assertions on and off. - With the fix:
--tests 'org.apache.lucene.analysis.hunspell.*'plus the independent reproducer's class: 50 classes (the package's 46 test classes plus the four added ones), 176 tests, 0 failures, 13 skipped (tests that need external data: dictionaries, word corpora or a Hunspell checkout; one is@Ignore), with assertions on and off. So this run did not cover real dictionaries or corpora. :lucene:analysis:common:check -x test(google-java-format, ecj lint, forbidden APIs) passes with the fix and the regression test.- Together with the sibling short-condition fix and both bugs' tests: 52 classes, 195 tests, 0 failures, 13 skipped.
- Earlier, on
ea57360912edwith the fix shadowed: Lucene's hunspell test classes 146 of 146; the exhaustive harness 0 mismatches over 144,482,592 checks with both fixes; commit isolation: the introducing commit plus only this change is correct.
Build the strip check with new RegExp(escapeDash(...)) and run it on the strip, so one engine and one escaping rule handle the whole condition and java.util.regex goes away.
Trade-offs
A larger change; Lucene RegExp and java.util.regex differ on other characters (testNonHunspellPatternCharacters pins some of that), so it needs its own review. Not run.
Call escapeDash on the condition at the top of compile.
Trade-offs
The pattern counter (:131-151) treats a \ outside brackets as its own pattern, so a condition with a dash outside brackets (such as a-) would be miscounted. Read only; not run.
Elasticsearch and OpenSearch read Hunspell dictionaries from the node's configuration directory, so only an operator's own dictionary can carry such a rule; documents, queries and spell-checked words cannot trigger it.
A bounded-exhaustive check compared the real AffixCondition with an independent model of the hunspell(5) rules: every condition of up to 3 patterns from a, b and -, with strips up to 3 and stems up to 2 characters from a, b, c and - (144,482,592 checks). It flagged a "dash set over the strip" class; a public-API test confirmed it.
The method in full157 words
Method: bounded-exhaustive translation validation (the hunt's "semi-formal" tier).
- A harness drove the real
AffixCondition(theuniqueKeyshortcut,compileandacceptsStem, asDictionaryandStemmeruse them) against a separate reference of the hunspell(5) condition semantics, over every condition of 1-3 patterns from 34 element kinds over {a, b, -} (dot, literals, every bracket set and its negation), strips of up to 3 and stems of up to 2 characters over {a, b, c, -}: 144,482,592 checks. - On the stock jar it found 4,740,482 mismatches in two classes: this one (a dash set over the strip) and a sibling (a condition shorter than the strip checked against the wrong end). With both fixes: 0 mismatches; with this fix alone only the sibling class remains.
- The finder reduced it to a public-API JUnit and surveyed 191 public dictionaries (0 affected rules). An independent reproducer, cross-model reviews, a devil's advocate and an adjudication then checked it (see the checks).
No prior report found (2026-10-26): 57 tool queries, full-text dumps of 422 Hunspell issues and PRs, the files of all 411 open PRs, JIRA, the mailing lists and the web. The closest items are the introducing PR and the 2014 dash fix LUCENE-5777, which states the contract.
Searches4 searches
Registry verdict new: no report, issue, PR, review comment or discussion of this bug by anyone else was found. Related, not reports: lucene-solr#2423 / #10845 (LUCENE-9806, the introducing PR and issue; one general review remark, "I think I just get worried when I see escaping for RegExp syntax", names no dash, range or wrong result); #6839 (LUCENE-5777, 2014, a different, fixed dash bug that created escapeDash); #6830 (LUCENE-5768) and #6560 (LUCENE-5497), earlier condition fixes in the pre-split code; #16422, the only open PR touching a hunspell file (Dictionary.java only). Gaps: GitHub search does not index code; a report naming neither Hunspell, the class nor the symptom would be missed; private channels are not visible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream; dev@, java-user@, issues@, commits@; JIRA; web | 2026-10-26 | a scripted prior-report search, 57 queries, 96 hits | none the same bug; only related history |
| GitHub full text (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, lucenenet) | 2026-10-26 | dumps of 422 items (218 apache/lucene, 94 apache/lucene-solr, 74 Elasticsearch, 23 OpenSearch, 13 lucenenet) matching "hunspell" (title, body, comments, review comments), grepped for dash, hyphen, escapeDash, AffixCondition, PatternSyntaxException and character-class terms | no report |
| Open PRs and upstream code | 2026-10-26 | changed files of all 411 open apache/lucene PRs; AffixCondition.java on upstream main and branch_10x | only #16422 touches a hunspell file (Dictionary.java); lines 108/113/120 unchanged; not fixed |
| JIRA (LUCENE, SOLR), lists.apache.org, web | 2026-10-26 | extra JIRA REST, list and web queries | no report |
Introduced by LUCENE-9806 (3a99e2aa82d8, 2021-02-24), a speed-up that dropped the whole-condition dash escaping; first released in 8.9.0 through a backport. 8.8.2 is correct; 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main fail. Not fixed upstream (re-checked 2026-10-28: a later commit touched the file, not the three calls).
Versions tested9 rows
Introduced by 3a99e2aa82d860b6a319cea39607ab3c8ef590b5 "LUCENE-9806: Hunspell: speed up affix condition checking (#2423)" (@donnerpeter, 2021-02-24; apache/lucene-solr#2423; issue #10845, a sub-task of LUCENE-9687, #10726). It created AffixCondition.compile with the three raw strip.matches calls and removed the whole-condition escapeDash from Dictionary.parseAffix, which had escaped the dash since the 2014 rewrite (LUCENE-5468) and LUCENE-5777. It reached 8.x in the squashed branch_8x backport c6b964a11576 (lucene-solr#2499, 2021-05-28; from GitHub metadata and the saved backported file, since the local clones lack that commit). Stemming existed before 8.9.0 and was correct; the spell-checking class first ships in 8.9.0. The three calls are unchanged at every 9.x and 10.x tag that contains the commit; releases before 8.0.0 were not run. Upstream main changed AffixCondition.java once after a9779ef3c193 (c5ab3e57b9b, #16919, 2026-10-28: two catch/throw clauses at :126 and :147); the three calls are unchanged and the fix and the test still apply to upstream main and branch_10x.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | gradle: the primary test fails 3 of 4, the four test files 13 of 24, assertions on and off |
main ea57360912ed | affected | finder, independent reproducer, devil's advocate, judge and follow-up re-runs: fail with -ea and -da |
| 10.5.2, 9.12.3 | affected | the primary JUnit fails 3 of 4; the reproducer's 6 of 8 (release jars) |
| 9.0.0, 8.9.0, 8.11.4 | affected | main() probe (Dictionary + Stemmer, cab/bac): all 4 words wrong |
| 8.8.2, 8.8.1, 8.0.0 | not affected | main() probe: all 4 words correct |
parent e1ff4c13541 | bisect | hunspell package shadowed over the 9.0.0 jars: all 4 correct |
3a99e2aa82d8 (LUCENE-9806) | bisect | all 4 wrong |
3a99e2aa82d8 + escapeDash at the 3 calls | not affected | all 4 correct |
fix A on a9779ef3c193 | not affected | 176 tests, 0 failures, 13 skipped, assertions on and off; with the sibling fix 195 tests, 0 failures, 13 skipped; lint passes |
An independent reproducer, cross-model reviews, a works-as-designed devil's advocate, an adjudication and a credibility audit. Every re-run on main failed with assertions on and off, and every run with the fix passed. A cross-model review could not run code and voted not real on procedure only while agreeing on the root cause.
Checks and verdicts470 words
Each check ran or read on its own; the registration check and this doc re-ran the tests with fresh seeds and through gradle on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (bounded-exhaustive check) | candidate | The harness flagged the dash-set class; JUnit fails 3 of 4 on main, 10.5.2 and 9.12.3; the fix passes 4 of 4 and Lucene's 146 hunspell tests; 0 affected rules in 191 public dictionaries. |
| cross-model review, refute lens | not real, low (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6) could not run code; it voted not real on procedure only. Its reading agreed with the root cause and found the inputs in contract; a separate re-run failed 3 of 4. |
| independent reproducer | real | Own 8-test JUnit in another package fails 6 of 8 on main (-ea, -da), 10.5.2 and 9.12.3, including the reversed [z-a] and the stem filter; controls pass; an escapeDash-only patch passes 8 of 8; C Hunspell 1.3.2 agrees; 8.8.2 correct, 8.9.0 wrong. |
| cross-model review, works-as-designed lens | real, medium | Intended-behaviour argument rated weak. Separate executed checks: the JUnit fails (-ea, -da), 8.8.2 passes and 8.9.0 fails, and a spell() driver on two dictionaries flips 7 of 9 words stock vs fixed (the 2 controls agree). A later C Hunspell 1.3.2 run on the same two dictionaries agrees with the fix on all 9 words. |
| intended-behaviour devil's advocate | real | No intended reading survives; own variants reach each strip.matches call and fail 3 of 4 on main, pass 4 of 4 with the fix; C Hunspell 1.7.2 (UTF-8 and ISO8859-1) gives the expected values. |
| adjudication | confirmed, high | Re-ran the finder's JUnit (-ea, -da: 3 of 4 fail), the reproducer's (6 of 8 fail) and with the fix (8 of 8 pass); ran C Hunspell 1.3.2 on the dictionary. |
| security screen | security none | Trigger only in the trusted dictionary; 18 dash forms load cleanly, 14,877 spell/stem/suggest calls raise nothing; the regex check runs only at load. |
| credibility audit | keep | Re-ran both JUnits with fresh seeds (3 of 4 and 6 of 8 fail); score and wording judged honest. |
| this doc's gradle runs | confirmed | Stock main: 13 of 24 fail across the four test files, assertions on and off. With the fix: 176 tests, 0 failures, 13 skipped, both modes; with the sibling fix too, 195 tests, 0 failures; lint passes. |
| fact checks (independent and cross-model) | facts hold; wording fixed | An independent fact check re-ran the repro, the unit test and the fixed package and confirmed the bug, the fix and the history; a reviewer from another model family (OpenAI GPT-5.6) found the central conclusion sound. Their precision errors (counts, the assertion mode of the command, the first release with SortingStrategy, scope wording) are fixed in this doc. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 485 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,462 words
- Registry title
- Hunspell: the part of an affix condition that overlaps the strip is checked with java.util.regex without escaping the dash, so a bracket such as [a-c] there means the range a..c instead of the set {a, -, c} that hunspell(5) defines, and the rule is applied or dropped wrongly
- Area
- analysis / hunspell (AffixCondition: affix conditions used by Hunspell.spell/suggest and by stemming through Stemmer/HunspellStemFilter)
- Priority
- Low, rank 467: Hunspell misreads a dash range in the strip part of an affix condition, which only an unusual hand-written rule triggers.
- Impact class
- silent wrong results
- Impact note
- Only applications that use Lucene's opt-in Hunspell support (Hunspell.spell, and by code reading suggest; or stemming through HunspellStemFilter/Stemmer) with a dictionary that has an affix condition with a dash between two characters inside [ ] at a position that overlaps the rule's strip are hit: that rule is applied where Hunspell skips it or dropped where Hunspell applies it, silently and deterministically for every word it covers, with no scale needed; none of 191 surveyed public dictionaries has such a rule that changes a decision, and every release from 8.9.0 to 10.6.0 has it (10.6.0 by reading).
- Summary
- You load a Hunspell dictionary with the rules 'SFX A b x [a-c]' and 'SFX A b y [^a-c]' on the root ab and expect [a-c] to mean what hunspell(5) says, the set {a, -, c} ('Dash hasn't got special meaning'), so the first rule never applies (ab ends in b) and the second one does: C Hunspell 1.3.2 and 1.7.2 reject ax and accept ay. Instead Lucene's Hunspell.spell accepts ax and rejects ay (likewise xa and ya for the PFX twins on ba), and HunspellStemFilter stems ax to ab and leaves ay unstemmed. AffixCondition.compile decides the part of a condition that overlaps the strip once, at load time, with String.matches (java.util.regex) on the raw condition text, which reads [a-c] as a range, while the part checked against the stem goes through escapeDash; a reversed pseudo-range such as [c-a] even makes java.util.regex throw, and the rule is silently turned into 'never applies'.
- Introduced
- 3a99e2aa82d860b6a319cea39607ab3c8ef590b5, LUCENE-9806 'Hunspell: speed up affix condition checking' (apache/lucene-solr#2423, by GitHub donnerpeter, merged 2021-02-24 into lucene-solr master, which became 9.0; migrated issue apache/lucene#10845, a sub-task of LUCENE-9687 / #10726) [read: the commit created AffixCondition.compile with the three raw strip.matches calls and removed the whole-condition escapeDash from Dictionary.parseAffix; git blame on main attributes AffixCondition.java:104-123 to it, except :124 (ad24abfb0df, 2025, unnamed catch variable), and git log -L over the strip.matches block returns only this commit] [exec: with the hunspell package of the parent e1ff4c13541 shadowed over the 9.0.0 jars the version-portable probe is correct on all 4 words, with that of 3a99e2aa82d8 all 4 are wrong, and 3a99e2aa82d8 plus only escapeDash(...) at the three calls is correct again]. Reached 8.x through the squashed branch_8x backport c6b964a11576 (lucene-solr#2499, LUCENE-9687, 2021-05-28; the backported file has the same raw calls). A regression: Lucene had escaped the dash in the whole condition since the 2014 Hunspell rewrite (LUCENE-5468) and LUCENE-5777 (1e881bf7931, 2014-06-19); the PR's stated purpose was speed only (2021-02-24 (main); 2021-05-28 (branch_8x backport))
- First affected version
- 8.9.0 [exec: the version-portable probe (Dictionary + Stemmer, SFX and PFX, three-letter roots) is wrong on all 4 words on 8.9.0 and correct on 8.8.2, 8.8.1 and 8.0.0]; on the 9.x line 9.0.0 [exec]
- Affected versions
- 8.9.0 - 8.11.4, 9.0.0 - 9.12.3, 10.0.0 - 10.6.0 (10.6.0 by reading) and main ea57360912ed. Executed: the portable probe fails on 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main and passes on 8.0.0, 8.8.1 and 8.8.2; the finder's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3 (it needs SortingStrategy, so not on 9.0.0/9.1.0); the independent reproduction's JUnit fails 6 of 8 on main (-ea and -da), 10.5.2 and 9.12.3. [read] the releases in between: the three calls are unchanged at every 9.x and 10.x tag that contains 3a99e2aa82d8 (git log -L), and on branch_8x c6b964a11576 is the only change to AffixCondition.java, which first ships in the 8.9.0 jar. Stemming (Dictionary + Stemmer, HunspellStemFilter) existed before 8.9.0 and was correct there; the Hunspell spell/suggest class first ships in 8.9.0, so spell() is affected in every release that has it. Not fixed upstream: main 645d4fe14a2 still fails [exec 2026-10-29, -ea and -da]; AffixCondition.java:108/113/120 are unchanged there (#16919 touched the file only at :126 and :147) and on branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b [read 2026-10-29]. Releases before 8.0.0 not executed. All runs on aarch64 with JDK 25.0.3
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only, the prior-report record verdict new: a scripted prior-report search, 57 queries over GitHub issues and PRs (20), commits (3), downstream repositories (2), the dev@, java-user@, issues@ and commits@ lists (20), JIRA (6) and the web (6), 96 hits, the only 'high' one mechanical (a Stack Overflow question quoting the man page); full-text GraphQL dumps of 423 items (every apache/lucene (218) and apache/lucene-solr (94) item matching 'hunspell', plus Elasticsearch, OpenSearch and lucenenet) grepped for dash, hyphen, escapeDash, AffixCondition, PatternSyntaxException and character-class terms; the changed files of all 411 open apache/lucene PRs; extra JIRA REST, lists.apache.org and web queries; git history; the finder's, the independent reproduction's and the intended-behaviour check's searches agree). Closest items: lucene-solr#2423 / #10845 (LUCENE-9806, the introducing PR and issue; one general review remark by rmuir about hand-escaping RegExp syntax in a helper, with no mention of dashes, ranges or a wrong result), #6839 (LUCENE-5777, 2014, a different dash bug that created escapeDash and states the contract), #6830 (LUCENE-5768) and #6560 (LUCENE-5497), earlier condition and escaping fixes in the pre-split code, and #16422, the only open PR touching a hunspell file (Dictionary.java only). Not fixed upstream (AffixCondition.java:108/113/120 unchanged on upstream main 645d4fe14a2, branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b; #16919 touched the file only at :126 and :147; recheck 2026-10-29).
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree: the three repro classes and the proposed unit test, stock and fixed, assertions on and off; the whole hunspell test package with the fix, alone and together with the fix of the sibling short-condition bug; the module's lint check; the main refresh) and mainea57360912ed(hunt, independent checks, adjudication, follow-up re-runs, Lucene's 146 hunspell tests stock and fixed); the release jars 8.0.0, 8.8.1 and 8.8.2 (correct) and 8.9.0, 8.11.4, 9.0.0, 9.12.3 and 10.5.2 (wrong); the introducing commit3a99e2aa82d8, its parent and the commit plus the fix, shadowed over the 9.0.0 jars; C Hunspell 1.3.2 and 1.7.2 on the example dictionaries (spell verdicts and stems). JDK 25.0.3, Linux aarch64.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/AffixCondition.java at ea57360912ed: :86-129 compile (:98-102 unclosed-bracket tolerance, :106-109 whole condition within the strip, :108 strip.matches(regex), :111-117 PFX split, :113 strip.matches(condition.substring(0, split)), :119-123 SFX split, :120 strip.matches(condition.substring(split)), :124-125 catch PatternSyntaxException -> ALWAYS_FALSE, :126-127 IllegalArgumentException 'On line'), :176-184 regexpCondition (:179 new RegExp(escapeDash(condition))), :186-205 escapeDash (comment 'dash hasn't got special meaning (we must escape it)')lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:760 (the only caller of AffixCondition.compile, at dictionary load); lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-44 (class javadoc: replacement for native Hunspell for many languages, the listed unsupported cases) and :71-72 (spell javadoc 'considered correct according to Hunspell rules')lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestAffixCondition.java (testConditionHasBracketsIntersectingWithStrip uses [io], no dash) and double-escaped.aff ('[^\-]ar', strip ar: the class is on the stem side), per the cross-model review and the intended-behaviour checkhunspell(5), Hunspell 1.7.2 source man/hunspell.5:541-544 ('Characters in braces sign an arbitrary character from the character subset. Dash hasn't got special meaning, but circumflex (^) next the first brace sets the complementer character set.'), per the independent reproduction and the intended-behaviour check
- The finding in 30 seconds
a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/AffixCondition.java:86-129, 153-155, 174-206a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:749, 760
- Who is affected
a Lucene main checkout lucene/analysis/common/src/java/module-info.java:55, 204 and lucene/analysis/common/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory:64 (the only main-source files outside the package that name it: the export and the HunspellStemFilterFactory registration)
- Why it looks unintended
a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:35-44, 71-72; AffixCondition.java:179, 186-206; HunspellStemFilter.java:39a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestAffixCondition.java:61-68; double-escaped.aff:4a Lucene main checkout lucene/CHANGES.txt:12289-12290
- Suggested fix
a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/AffixCondition.java:131-151, 186-206; a Lucene main checkout lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestAffixCondition.java:70-75
- TestAffixConditionDashInStrip.java (JUnit test):
repro/LFV-376/src/org/apache/lucene/analysis/hunspell/TestAffixConditionDashInStrip.java(38 lines)
A picture of the repro's worked example, made in parallel with this doc; the alt text describes the example.
assets/LFV-376/bug.png(485 KB)
![The root ab with the rules SFX A b x [a-c] and SFX A b y [^a-c]. Hunspell reads [a-c] as the characters a, - and c, so the stripped b is not in it: ay is a word and ax is not. Lucene's strip check reads [a-c] as the range a to c, which contains b, so it accepts ax and rejects ay.](../assets/LFV-376/bug.png)