All findings · Lucene finding · LFV-557 · ukrainian-morfologik-analyzer-no-normalize

UkrainianMorfologikAnalyzer has no normalize(): prefix, wildcard, regexp, range and fuzzy terms skip its normalization

The stock Ukrainian analyzer lower-cases and maps apostrophes, stress marks, soft hyphens and ґ at index time but never implemented normalize(), which query parsers use for prefix, wildcard, regexp, range and fuzzy terms. So м’яс* and Luc* miss the document м’ясо Lucene; wrong since 6.2.0 (classic parser, upper case: since 7.0.0).

Summary

Priority
Medium, rank 120The stock Ukrainian analyzer skips its normalization for every multi-term query, so ordinary capitalised or apostrophe words miss silently.
Impact
silent wrong results
Since
6.2.0 (LUCENE-7355, 2016)introduced 2016-06-28 · over 10 years
Affected
6.2.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive metamorphic scan (normalize vs the indexed token)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

Query parsers cannot run the full analyzer on a partial word such as м’яс* (it would be stemmed), so they call Analyzer.normalize(), which must return "the representation that it would have in the index". An analyzer supplies that through two hooks, one for char filters and one for token filters; this one implements neither.

Summary card: you index "м’ясо Lucene" with UkrainianMorfologikAnalyzer and parse м’яс* and Luc* with the same analyzer; expected body:м'яс* body:luc* (the index form), actual body:м’яс* body:Luc* (unchanged, 0 hits). Inside, indexing runs MappingCharFilter and LowerCaseFilter and stores м'ясо lucene, while Analyzer.normalize runs initReaderForNormalization and normalize(..., TokenStream), neither overridden, so the terms stay м’яс and Luc. Bullets: affected query kinds and characters, every release 6.2.0 to 10.5.2 and main, the two-override fix, and the trade-off for capitalised lemmas such as Київ*.
The repro step by step: the document м’ясо Lucene is indexed as м'ясо lucene, while the query terms м’яс and Luc pass through both normalization hooks unchanged, so the parsed queries find 0 hits. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index the text м’ясо Lucene (typographic apostrophe U+2019) with new UkrainianMorfologikAnalyzer(). The analyzer stores the terms м'ясо (plain apostrophe) and lucene (lower case).
  2. Search with the classic QueryParser and the same analyzer for м’яс* or Luc*, typed exactly as in the document. You expect 1 hit, as м'яс* and luc* get.
  3. You get 0 hits, with no error. A prefix is a partial word, so the parser does not run the full analyzer on it (stemming would break it); it asks Analyzer.normalize() for the term's index form instead.
  4. This analyzer overrides neither of the two hooks behind normalize(), so the term comes back unchanged: normalize("Lucene") is Lucene, the prefix stays м’яс, and no indexed term starts with either.
  5. The same happens for prefix, wildcard, regexp and range terms that keep a typographic apostrophe, a stress mark, a soft hyphen or ґ (no indexed term contains one), and for those with a capital letter when the word is indexed in lower case (every word the dictionary does not know, and every lower-case lemma); a capitalised term still finds a capitalised dictionary lemma (Київ* finds Київ). Term and phrase queries and the index are fine; fuzzy misses only beyond its edit distance (Lucene~1 finds the document, LUCENE~2 does not).

Who is affected

Applications that index Ukrainian text with the stock analyzer (two downstream search servers' Ukrainian plugins construct it unchanged) and accept prefix, wildcard, regexp, range or fuzzy syntax. Default settings; no scale needed. Ordinary spelling triggers it: the apostrophe inside м’ясо or п’ять, or a capital on a lower-cased word. Hits silently go missing.

Mechanism, terms and numbers674 words

UkrainianMorfologikAnalyzer normalizes every word before it is indexed: its initReader maps the apostrophe variants U+2019, U+2018, U+02BC, backtick and U+00B4 to ', removes the stress mark U+0301 and the soft hyphen U+00AD and maps ґ to г, and its chain lower-cases before the dictionary lemmatizer. So м’ясо Lucene is indexed as м'ясо and lucene. For prefix, wildcard, regexp, range and fuzzy terms the classic and flexible query parsers call Analyzer#normalize (the simple parser for prefix and fuzzy terms), documented to return "the representation that it would have in the index". The class overrides neither initReaderForNormalization nor normalize(String, TokenStream), and both default to the identity, so normalize("Lucene") returns Lucene and the parsed queries м’яс*, Luc*, Luce?e and [Lucene TO Lucene] find nothing, while м'яс* and luc* find the document. Term and phrase queries and the index are unaffected. LUCENE-7355 created normalize in 2016 and added overrides to 42 named stock analyzers (plus CustomAnalyzer), but skipped this class, committed a week before the change was authored: Analyzer#normalize is wrong in every release from 6.2.0 to 10.5.2 and main, and the classic parser's upper-case symptom starts in 7.0.0. Two overrides fix it on the query side, without reindexing. The character-mapping one has no side effect; the lower-casing one makes prefixes and ranges of capitalised dictionary lemmas such as Київ* stop matching and lower-cases regexp escapes (\D to \d), as every stock analyzer that lower-cases in normalize already does.

Terms

  • Analyzer chain: the steps that turn text into indexed terms. This analyzer's chain is a MappingCharFilter (initReader), then StandardTokenizer -> LowerCaseFilter -> StopFilter -> (SetKeywordMarkerFilter) -> MorfologikFilter, the dictionary lemmatizer (UkrainianMorfologikAnalyzer.java:151-154, :165-177).
  • NORMALIZER_MAP (:47-64): U+2019, U+2018, U+02BC, backtick and U+00B4 to '; U+0301 (stress mark) and U+00AD (soft hyphen) removed; ґ/Ґ to г/Г.
  • Multi-term query: prefix (Luc*), wildcard (Luce?e), regexp (/Luc.*/), range ([a TO b]) and fuzzy (Lucene~1). Their term is a pattern or a partial word, so the parsers do not tokenize or stem it.
  • Analyzer#normalize(field, text) (Analyzer.java:202-213): "Normalize a string down to the representation that it would have in the index. ... This method uses initReaderForNormalization ... to apply necessary character-level normalization and then normalize(String, TokenStream) in order to apply the normalizing token filters." Both hooks default to the identity (:122-129, :279-286).

The mechanism (main ea57360912ed)

  1. Indexing runs initReader and createComponents: м’ясо Lucene becomes the terms м'ясо and lucene.
  2. The parsers send multi-term terms through normalize: classic QueryParserBase.java:550, :556 (range), :646, :656 (wildcard), :683 (regexp), :712 (prefix), :729 (fuzzy); simple SimpleQueryParser.java:573, :605; flexible WildcardQueryNodeProcessor.java:66, :76, TermRangeQueryNodeProcessor.java:138-139, FuzzyQueryNodeProcessor.java:59, RegexpQueryNodeProcessor.java:43.
  3. The class overrides only initReader and createComponents, so normalize returns its input: м’яс* stays body:м’яс*, Luc* stays body:Luc*.
  4. No indexed term can contain a mapped character: every pass-through token goes through the char filter, and an exhaustive scan of all 3,485,758 entries of the bundled dictionary found none in any lemma or form. Pass-through tokens are lower-cased; a dictionary word is replaced by one or more lemmas, some capitalised. So the unchanged м’яс and Luc cannot match; a capitalised term matches only a capitalised lemma (Київ* finds Київ).

The repro's numbers (classic QueryParser, one document м’ясо Lucene; the fuzzy row comes from the security probe's seven-document index, document Lucene)

queryparsed asexpectedactual
м'яс*, luc*, Lucene (controls)body:м'яс*, body:luc*, body:lucene1 each1 each
м’яс*body:м’яс*10
Luc*, Luce?e, [Lucene TO Lucene]unchanged1 each0 each
Lucene~1 / LUCENE~2 (fuzzy)unchanged1 / 11 / 0

normalize itself, with each word's indexed form checked first: Lucene -> Lucene (index lucene), м’ясо -> м’ясо (index м'ясо), м'я\u0301со -> unchanged (index м'ясо), сек\u00ADрет -> unchanged (index секрет), ґрунт -> ґрунт (index грунт).

Exposure and likelihood344 words
  • Who is exposed. Applications that index with the stock UkrainianMorfologikAnalyzer and pass multi-term syntax through the classic, flexible or simple query parser, or call Analyzer#normalize themselves. By code reading only, the analysis-ukrainian plugins of two downstream search servers (Elasticsearch and OpenSearch) construct the stock class unchanged; their query paths were not tested.
  • Default or opt-in. Default constructor, default parser settings, default IndexWriterConfig, one document. The opt-in is the language analyzer itself (the optional analysis-morfologik module, Ukrainian-language indexes) and multi-term syntax.
  • Triggers. A prefix, wildcard, regexp or range term that keeps one of the five apostrophe forms the analyzer maps (U+2019, U+2018, U+02BC, backtick, U+00B4), a stress mark, a soft hyphen or ґ (no indexed term contains one), or that has an upper-case letter where the word is indexed in lower case (every pass-through word and every lower-case lemma). A capitalised term still matches a capitalised dictionary lemma (stock Київ*, Гет*, Чайков*: 1 hit each). The apostrophe sits inside common words (м’ясо, п’ять, сім’я); ґ is a letter of the alphabet, so a correctly spelled ґрунт* cannot match грунт.
  • Scale (measured on synthetic strings). A scan over every 1- and 2-character string of a 110-character multilingual alphabet plus random 3- and 4-character strings: of the 14,854 strings that index as one token (lemmatization almost entirely switched off), normalize disagreed with the indexed token on 7,983: 7,979 indexed as the lower-cased, mapped input and 4 as the capitalised lemma Гб, so every disagreement is a case or mapped-character difference. All 4,786 disagreeing strings checked were missed by classic, flexible and flexible multi-field prefix, wildcard, regexp and range queries and by simple-parser prefix queries; fuzzy still found 3,606. All 5,246 agreeing strings were found.
  • What you see. Fewer hits, no error. Term and phrase queries and the index are unaffected; for ranges an upper-case bound compares in the wrong representation and can also admit extra terms.
  • No workaround inside the class. It is final, so users cannot subclass it; an AnalyzerWrapper works (Suggested fix, option E).

Minimal repro

UkrainianMorfologikAnalyzer.normalize skips the apostrophe mapping its initReader applies, so normalize("f", "м’ясо") (with U+2019) differs from the indexed term.

TestUkrainianAnalyzerNormalizeApostrophe.java (JUnit test)30 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.uk;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUkrainianAnalyzerNormalizeApostrophe extends LuceneTestCase {

  public void testNormalizeMapsTypographicApostrophe() throws Exception {
    try (Analyzer a = new UkrainianMorfologikAnalyzer()) {
      // initReader maps U+2019 to ' so the index holds м'ясо; normalize must return the same term
      assertEquals("м'ясо", a.normalize("f", "м\u2019ясо").utf8ToString());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-557/, 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:$LUCENE/lucene/analysis/morfologik/build/libs/lucene-analysis-morfologik-$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.uk.TestUkrainianAnalyzerNormalizeApostrophe
# the module(s) analysis-morfologik also need their third-party runtime jars
Expected vs actual
Expected "м'ясо" (the term the index holds), actual "м’ясо" (input unchanged): expected:<м[']ясо> but was:<м[’]ясо>.

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

More files, environment, full output32 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
    => org.junit.ComparisonFailure: expected:<м[']ясо> but was:<м[’]ясо>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.uk.TestUkrainianAnalyzerNormalizeApostrophe.testNormalizeMapsTypographicApostrophe(TestUkrainianAnalyzerNormalizeApostrophe.java:27)

Why it looks unintended

  • Analyzer.normalize promises "the representation that it would have in the index", char filters included (Analyzer.java:203-211).
  • LUCENE-7355 gave 42 named stock analyzers normalize overrides and skipped this one; the Russian, Polish, Thai and Japanese analyzers have one.
  • No indexed term contains a mapped character, so keeping one can only miss. The class is final.
The full argument311 words
  • Contract. Analyzer#normalize: "Normalize a string down to the representation that it would have in the index", applying "necessary character-level normalization" through initReaderForNormalization (Analyzer.java:203, :209-211), which is "typically a subset of the CharFilters that are applied in initReader" (:280-282). The class's char map is named NORMALIZER_MAP (UkrainianMorfologikAnalyzer.java:47). The Analyzer class javadoc example keeps the normalization filter in normalize and drops only the stemming filter (Analyzer.java:45-60).
  • MIGRATE.txt (LUCENE-7355, 7.0): "CommonQueryParserConfiguration.setLowerCaseExpandedTerms removed ... This option has been removed as expanded terms are now normalized through Analyzer#normalize."
  • Siblings. RussianAnalyzer and PolishAnalyzer override normalize with LowerCaseFilter; ThaiAnalyzer and JapaneseAnalyzer also override initReaderForNormalization with their char filters. This is the only stock search analyzer whose chain lower-cases and char-maps and that overrides neither hook. Two analyzers follow the convention only in part: JapaneseCompletionAnalyzer lower-cases at index time but overrides only initReaderForNormalization (JapaneseCompletionAnalyzer.java:52, :62), and PersianAnalyzer char-filters in initReader (PersianAnalyzer.java:158-160) without initReaderForNormalization, which the devil's advocate judged a tokenization aid (ZWNJ to space).
  • History. The class was committed on 2016-06-21 (LUCENE-7287); LUCENE-7355 (authored 2016-06-28, committed 2016-07-12) added overrides to 42 named stock analyzers (plus CustomAnalyzer, the test framework's MockAnalyzer and Solr's TokenizerChain) and not to it, and no commit since has. On LUCENE-7355 its author described the design as applying "all normalization filters (lowercasing, ascii folding, etc.) but not transformations (stop word removal, stemming, etc.)". LUCENE-7841 added ґ -> г because "the search will benefit". LUCENE-7429, the same omission in DelegatingAnalyzerWrapper, was fixed as a bug; LUCENE-8022 was resolved Invalid because the analyzer must override normalize, and here that analyzer is Lucene's own and final.
  • Tests. No upstream test asserts identity normalization; checkRandomData (through checkAnalysisConsistency) only checks that normalize does not throw ("TODO: what can we do besides testing that the above method does not throw?").
The devil's-advocate check340 words

The strongest "intended" case (an intended-lens reviewer from another model family rated it moderate; the devil's advocate and the refute review weak; a later cross-model fact check moderate overall, weak for the character mapping, moderate for the lower-casing): the identity defaults are documented and optional; the classic parser's javadoc still says prefix and wildcard terms "will not go through the default Analyzer, however, since normal Analyzers are unlikely to work properly with wildcard templates" (QueryParserBase.java:610-612, :695-697; :666-668 says the same for regexps with "regular expression templates"); 265,482 of the 3,485,758 dictionary entries (7.6%) have a lemma with an upper-case letter (proper nouns such as Київ, also all-caps forms), so a lower-casing normalize makes Київ* stop matching; the dictionary replaces a word with one or more lemmas (MorfologikFilter.java:121-135), so for dictionary words no single string is "the representation that it would have in the index"; and a lower-casing normalize also lower-cases regexp escapes and range bounds.

Why it fails: optional defaults let arbitrary analyzers skip the hooks; they do not say a stock analyzer that lower-cases and char-maps should, and every other such stock analyzer got at least one of the two overrides. The parser javadoc is outdated rather than a statement of intent: it still describes the removed lowercaseExpandedTerms setting ("Depending on settings, ... may be lower-cased automatically") and does not mention that the same methods call getAnalyzer().normalize(...) (:683, :712); MIGRATE.txt says expanded terms "are now normalized through Analyzer#normalize". Its "will not go through the default Analyzer" is still true of the full chain. The capitalised-lemma and one-to-many points concern only the lower-casing half: normalize can be exact only for pass-through words, and those are lower-cased; in the history probe's classic-parser runs the capitalised-lemma match exists only since 7.0.0 (the 6.x classic parser lower-cased the terms and missed Київ* too). The regexp-escape effect is what StandardAnalyzer already does on main (/\D+/ becomes /\d+/). The character-mapping half has no trade-off at all: no indexed term can contain a mapped character.

Suggested fix

Recommended Override only initReaderForNormalization (+5 lines): no side effect, the suggested first step

Lead with an override that has no side effect: initReaderForNormalization returning new MappingCharFilter(NORMALIZER_MAP, reader) (+5 lines, query side only, no reindex) fixes the apostrophe, stress-mark, soft-hyphen and ґ misses. Offer normalize returning new LowerCaseFilter(in) for discussion: it fixes Luc*, but capitalised lemmas (Київ*) stop matching and regexp escapes get lower-cased.

+  @Override
+  protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+    return new MappingCharFilter(NORMALIZER_MAP, reader);
+  }
All options and trade-offs5 options · 1,178 words

Suggested route: lead with option B and offer option A's lower-casing for discussion (the adjudication's advice and the cross-model fact check's); proposing A in one change with its trade-offs stated is the other route. Option A was validated in a scratch worktree at ea57360912ed (removed afterwards); option B through gradle (the repro, and the whole module in the fact checker's run) and the runner; the option-B variant of testNormalize through the runner; option E on main through the runner. The trade-off tables come from two classic-parser probes run against stock, A and B.

Override initReaderForNormalization and normalize (+10 lines): the complete fix for lower-cased words, with trade-offs (validated by the checks)

initReaderForNormalization reuses the existing NORMALIZER_MAP; normalize applies LowerCaseFilter, as RussianAnalyzer and PolishAnalyzer do. Query side only: indexing does not call these hooks. The code below is the whole file, which git apply applies to ea57360912ed. fix-with-test.diff adds testNormalize to TestUkrainianAnalyzer (the PR shape for this route). Add a CHANGES entry under Bug Fixes, and fix the outdated QueryParserBase javadoc in the same change (option C).

diff --git a/lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java b/lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java
index 3915d264d7f..c3b8468b666 100644
--- a/lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java
+++ b/lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java
@@ -153,6 +153,16 @@ public final class UkrainianMorfologikAnalyzer extends StopwordAnalyzerBase {
     return new MappingCharFilter(NORMALIZER_MAP, reader);
   }
 
+  @Override
+  protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+    return new MappingCharFilter(NORMALIZER_MAP, reader);
+  }
+
+  @Override
+  protected TokenStream normalize(String fieldName, TokenStream in) {
+    return new LowerCaseFilter(in);
+  }
+
   /**
    * Creates a {@link org.apache.lucene.analysis.Analyzer.TokenStreamComponents} which tokenizes all
    * the text in the provided {@link Reader}.

Trade-offs

Prefixes of capitalised dictionary lemmas stop matching, because the index holds them capitalised and the query term is now lower-cased (classic parser; documents м’ясо Lucene, Київ, Цих Чайковського і Ґете.):

querystockoption Aoption B
м’яс*011
Luc*, Luce?e, [Lucene TO Lucene]0 each1 each0 each
Київ* (lemma Київ)101
Гет* (lemma Гете)101
Ґет*001
Чайков* (lemma Чайковський)101
київ*000

A prefix typed in lower case (київ*) finds nothing on stock, A or B; in the history probe's classic-parser runs the capitalised-lemma match exists only since 7.0.0 (the 6.x classic parser lower-cased the terms). 265,482 of the 3,485,758 dictionary entries (7.6%) have a lemma with an upper-case letter. SOLR-11835 (2018) says the dictionary holds proper names in lower case since 6.6; on main the analyzer emits capitalised lemmas (testCapsTokenStream asserts Чайковське, Чайковський and Гете; the probes show Київ), so reconcile the two before describing the side effect. Cost: one MappingCharFilter and one LowerCaseFilter over one query term; nothing at index time.

The whole regexp is lower-cased, escapes included, so \D, \S, \W become \d, \s, \w (the opposite classes) and [^L] becomes [^l]; a range bound on a capitalised lemma stops matching. StandardAnalyzer, whose normalize already lower-cases, does the same to regexps today; with its lower-cased index the range still matches (parsed query and hits; the probe also indexes the document 2026):

querystockoption Aoption BStandardAnalyzer (control)
/\D+//\D+/ 3/\d+/ 1/\D+/ 3/\d+/ 1
/\S+//\S+/ 4/\s+/ 0/\S+/ 4/\s+/ 0
/\W+//\W+/ 3/\w+/ 2/\W+/ 3/\w+/ 2
/[^L]ucene//[^L]ucene/ 1/[^l]ucene/ 0/[^L]ucene/ 1/[^l]ucene/ 0
[Київ TO Київ]1[київ TO київ] 01[київ TO київ] 1

The regexp and range cases are in no repro or security-probe test; they come only from this measurement probe. The lower-casing half also turns a deny prefix written with a capitalised lemma (*:* -Київ*) into a no-op, so prefix rules on this field stay best effort either way.

What the checks validated

  • Scratch worktree at ea57360912ed: the repro passes with assertions on and off (it fails on stock); TestUkrainianAnalyzer 8 tests (9 with testNormalize), the whole analysis-morfologik suite (21 tests with the repro, 22 with testNormalize, assertions on and off) and :lucene:analysis:morfologik:check (google-java-format, ecj, forbidden APIs) pass. testNormalize fails on stock at its first assertion.
  • As a shadow source on main: the parser-level test 2 of 2, the independent reproducer's test 4 of 4, the intended-lens probe 2 of 2, the security probe 17 of 17. Back-ported to 7.0.0 every history-probe group passes; on 6.2.0 and 6.6.6 the normalize and simple-parser groups.

Override only initReaderForNormalization (+5 lines): no side effect, the suggested first step (validated by the checks) (recommended)

The character-mapping half alone: initReaderForNormalization returning new MappingCharFilter(NORMALIZER_MAP, reader). The snippet shows the added lines.

+  @Override
+  protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+    return new MappingCharFilter(NORMALIZER_MAP, reader);
+  }

Trade-offs

No side effect: no indexed term contains a mapped character, so nothing that matches today stops matching; Ґет* starts to find Гете; regexps and ranges keep their stock behaviour (option A's tables). Upper-case terms (Luc*, Luce?e, [Lucene TO Lucene]) still miss lower-cased words; the lower-casing can follow as a separate, discussed change.

What the checks validated

The repro then fails only on the upper-case entries ([Lucene -> Lucene (index holds lucene)], [Luc* -> body:Luc* hits=0]), through gradle and the runner; the fact checker's gradle run of the whole module with option B: 21 tests, 2 failures, only those repro entries, the 19 existing module tests pass. A test change for this route (testNormalize without its upper-case assertion, 5 assertions) passes git apply --check; through the runner TestUkrainianAnalyzer with it fails 1 of 9 on stock (м’ясо keeps U+2019) and passes 9 of 9 with option B and with option A, assertions on and off. It was not run through gradle check (it is the checked testukrainiananalyzer-test.diff minus one assertion line, with a shorter javadoc line). Trade-off columns B in option A's tables.

Also fix the outdated QueryParserBase javadoc

QueryParserBase.java:610-612, :666-668, :695-697 still say that, "depending on settings", the term "may be lower-cased automatically" (the lowercaseExpandedTerms setting removed in 7.0) and that it "will not go through the default Analyzer" (still true of the full chain); they do not say that the term goes through Analyzer#normalize. Documentation only; goes with A or B. Not built.

Lower-case the lemmas at index time (not recommended)

Would remove the capitalised-lemma trade-off of option A.

Trade-offs

Changes the indexed terms (reindex), breaks the upstream assertion testCapsTokenStream (Чайковське, Чайковський, Гете) and changes term queries. Not run.

Workaround for users until a fix ships: an AnalyzerWrapper (validated by the checks)

Wrap the stock analyzer in an AnalyzerWrapper whose wrapReaderForNormalization applies a copy of the nine-entry map (the original is private) and whose wrapTokenStreamForNormalization applies LowerCaseFilter; use it for indexing and parsing (indexing is unchanged).

Trade-offs

Same capitalised-lemma trade-off as option A; the copied map must be kept in sync by hand.

What the checks validated

On main with the classic parser: stock м’яс*, Luc*, Luce?e find 0; with the wrapper they, lucene and the phrase "м’ясо Lucene" find the document (1 each).

Downstream note

Why: a term that keeps a mapped character never equals an indexed term, and a capitalised term misses every lower-cased word (only capitalised lemmas such as Київ match), so a NOT rule written that way excludes nothing (*:* -Секрет* still returns секрет). Only the rule's author causes it. Out of scope of SECURITY.md; no known exploit.

How it was found

An AI finder ran a bounded-exhaustive metamorphic scan on the stock main jars: Analyzer.normalize(w) must equal the single token indexing produces for w, over all short strings of a multilingual alphabet. The Ukrainian analyzer disagreed on 7,983 of 14,854; every checked disagreeing string was missed by every non-fuzzy parser path. Code reading found the missing overrides.

The method in full296 words

Method: bounded-exhaustive metamorphic testing, a hunt unit (analyzer normalize consistency), 2026-10-26 (the hunt's "semi-formal" tier).

  • Inputs: 20,207 strings, every 1- and 2-character string over an explicit 110-code-point multilingual alphabet plus random 3- and 4-character strings (fixed seed).
  • Relation N1: for every stock analyzer and every input w that indexes as exactly one full-span token, Analyzer.normalize(f, w) equals that token. UkrainianMorfologikAnalyzer disagreed on 7,983 of 14,854 compared strings with lemmatization (almost entirely) switched off through the stem exclusion set (7,971 of 14,838 with the default constructor); the disagreements are case and mapped-character differences (7,979 indexed as the lower-cased, mapped input, 4 as the capitalised lemma Гб; the fact checker's re-run).
  • Relation N2 (parser reachability): each string, indexed alone, must be found by its own prefix, wildcard, regexp, range and fuzzy query through the classic, flexible, flexible multi-field and simple parsers. All 4,786 checked disagreeing strings were missed by every non-fuzzy path; fuzzy found 3,606; all 5,246 agreeing strings were found.
  • Then: a public-API JUnit; a review by an independent reviewer from another model family (OpenAI GPT-5.6, read-only: "not real, low", procedural, because it could not run its own test; it agreed with the root cause); an independent reproducer; an intended-behaviour review by the same model family (read-only: real, medium); a devil's advocate (with an exhaustive scan of the bundled dictionary); an adjudication; a history sweep; a prior-report search; a security assessment; a registry re-run; an audit. Neither reviewer from the other model family ran code; separate runs re-ran the finder's test, which failed.
  • Afterwards: a fact check of the brief and this doc (it re-ran the repro, the fix variants and the probes) and a read-only fact check by the other model family corrected overstatements about upper-case terms and added the regexp and range trade-offs.

Prior reports, history and checks

Prior reports

No prior report: 90 tool and 36 manual queries over GitHub issues, PRs and commits, JIRA, four mailing lists, downstream repositories and the web, plus the changed files of all 407 open PRs (2026-10-26). Closest: LUCENE-7429, the same omission in DelegatingAnalyzerWrapper, fixed as a bug.

Searches5 searches

32 hits read, none this bug. Limits: keyword search only; forums only through general web search; private security@ reports are invisible. Hunt sibling: LFV-534 (SerbianAnalyzer.normalize omits SerbianNormalizationFilter), the same bug class from the same hunt with a different fix site.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26a scripted prior-report search (26 issue and PR queries, 4 commit queries) and manual queries, including two all-of-GitHub class-name searches; the changed files of all 407 open PRsno report; #8342 (adds the class), #8409 (adds normalize), #8892 and #8836 (widened the map in initReader only) never discuss multi-term queries; the only open PR touching the file (#16911) deletes branch_10x
Related items (read)2026-10-26full-text regex scans of 12 JIRA and 13 GitHub issues#8482 (LUCENE-7429, DelegatingAnalyzerWrapper skipped by LUCENE-7355, fixed as a bug); #9070 (LUCENE-8022, a user's analyzer without normalize, Invalid); #9234, #8657, #9141 (CustomAnalyzer and factory normalization, other classes); #8489 (wildcard tokenization)
JIRA archive (LUCENE, SOLR)2026-10-2610 tool and 6 manual JQL queriesno report; SOLR-11835 (dictionary proper names, relevant to the fix trade-off)
Mailing lists2026-10-2640 tool queries over dev@, java-user@, issues@, commits@; a manual "Ukrainian" search over six lists (356 mails, every subject read)no thread about multi-term queries with this analyzer
Downstream and web2026-10-264 tool and 8 manual downstream-repository queries; 6 tool and 9 manual web queries (one in Ukrainian)generic wildcard-normalization items in other code only

History

Missing since LUCENE-7355 created Analyzer#normalize (authored 2016-06-28, committed 2016-07-12, first released in 6.2.0) and added overrides to 42 named stock analyzers, skipping this one, committed a week before the change was authored. The classic-parser upper-case and ґ symptoms start in 7.0.0. Executed on 6.2.0, 6.6.6, 7.0.0, 8.11.4, 9.12.3, 10.5.2 and main.

Versions tested7 rows

Introduced by the omission in LUCENE-7355 (GitHub #8409, "Leverage MultiTermAwareComponent in query parsers"; JIRA patch, by jpountz; commit subject "Add Analyzer#normalize() and use it in query parsers"): 7c2e7a0fb80 on branch_6x (first released in 6.2.0) and e92a38af90d on master (first released in 7.0.0), both authored 2016-06-28 and committed 2016-07-12. The analyzer came with LUCENE-7287 (#8342), 6ef174f5273 / 4a71e03a32f, 2016-06-21, with the char filter and LowerCaseFilter from the start. git log --all -G 'normalize|initReaderForNormalization' on the file returns nothing.

The master commit also removed the parsers' lowercaseExpandedTerms (MIGRATE.txt), which exposed the upper-case half in the classic parser from 7.0.0. LUCENE-7785 (2017-04-19) and LUCENE-7841 (effd3766e7f, 2017-05-25) widened the map in initReader only; the ґ mapping first shipped in 7.0.0.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedfails with -ea and -da, through gradle and the runner
upstream main ffa9d816eeb4, a95c754ee003 readsame code on 2026-10-26 (the fact checker's read), not fixed
10.5.2, 9.12.3 affectedfail (release jars; 10.5.2 with -ea and -da, 9.12.3 with -ea)
8.11.4, 7.0.0 affectedevery probe group fails; the back-ported fix passes on 7.0.0
6.6.6, 6.2.0 first affected (6.2.0)normalize and simple-parser prefix fail; the classic parser lower-cased expanded terms itself, so Luc* matches, and did not analyze prefix terms, so the classic м’яс* miss there is a 6.x parser limitation; the back-ported fix makes the normalize and simple-parser groups pass
6.1.0 last unaffectedneither the class nor Analyzer#normalize exists (compile error)
the other 75 release tags 6.2.1 to 10.5.1 readthe file lower-cases and char-maps and has no override (8.8.2 to 8.11.3 inferred)

Independent checks

An independent reproducer, a devil's advocate, an adjudication and an intended-lens reviewer from another model family say real; a refute-lens review by that family said "not real" only because it could not run code. The adjudication, a registry re-run, an audit and this doc's gradle run all saw it fail on stock and pass with the fix.

Checks and verdicts535 words

Neither cross-model reviewer ran code of its own; every executed check was run by the hunt's own AI tools and runner.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateN1/N2 scans; the parser-level JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the fix passes, and TestUkrainianAnalyzer 8 of 8 with it.
review (OpenAI GPT-5.6, read-only) not real (low), proceduralCould not write or run a test; agreed with the root cause, judged the inputs in contract, rated the intended case weak. A separate run re-ran the JUnit: fails 2 of 2.
Independent reproducer realOwn JUnit: 3 of 4 fail on main (-ea, -da), 10.5.2, 9.12.3, the RussianAnalyzer control passes; own fix 4 of 4; cross-version runs 6.2.0 to main; the trade-off probe.
Intended-lens review (OpenAI GPT-5.6, read-only) real (medium); intended case moderate, rejectedRoot cause agreed, fix judged correct (static). A separate probe fails 1 of 2 on main and passes with the fix.
Devil's advocate realBuilt and refuted the intended case; scanned all 3,485,758 dictionary entries (none with a mapped character; 7.6% capitalised lemmas).
Adjudication confirmed, highRe-ran the independent reproduction's JUnit (seed 5EED1234: 3 of 4 fail; 4 of 4 with the fix) and the finder's with -da (2 of 2 fail).
Security assessment out of scope of SECURITY.mdSecurity probe: 13 of 17 fail on main (-ea, -da) and 10.5.2 (deny rules that exclude nothing, three over-matching allow rules); 17 of 17 with the fix.
Registry re-run and audit keepFresh seeds: the finder's JUnit fails 2 of 2 (-ea, -da), the independent reproduction's fails 3 of 4 (-da, main and 10.5.2) and passes 4 of 4 with the fix.
This doc's fix validation reproduced; fix validatedGradle in a scratch worktree: the new module repro fails on stock (asserts on and off); with the fix it, TestUkrainianAnalyzer, the whole module suite and check pass; 10.5.2 and 9.12.3 jars fail through the runner.
Fact check of the brief and this doc bug confirmed; 2 wording errors, fixedRe-ran the gradle repro (2 of 2 fail, assertions on and off), the runner on main, 10.5.2 and 9.12.3, the fix variants, the module check, the trade-off, security and workaround probes, and the N1 scan; every number matched. Its two errors (an overstatement about capital letters; a truncated patch block) and its minor findings are fixed.
Cross-model fact check (OpenAI GPT-5.6, read-only) core bug real; 7 errors, 12 overclaims, 6 missing caveats, fixedFound the same capital-letter overstatement, the missing regexp and range trade-offs of the lower-casing half (now executed) and counter-precedents; advised leading with option B. Every item is fixed or answered.
Correction to the registry row registry wording to updateThe registry row's impact, likelihood and security notes still say upper-case terms always miss ("whenever the query term has an upper-case letter", "misses deterministically", "excludes nothing when its term has an upper-case letter"). As this doc now says, that holds for mapped characters, and for capital letters only against lower-cased words: capitalised dictionary lemmas such as Київ still match. The row is outside this step's files.

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

Finding record

LFV-557, snapshot of 2026-11-06

Registry title
UkrainianMorfologikAnalyzer overrides neither normalize() nor initReaderForNormalization, so prefix, wildcard, regexp, range and fuzzy terms from the query parsers skip its lower-casing and its apostrophe, stress-mark, soft-hyphen and ґ mapping and miss words spelled exactly as in the document
Area
analysis-morfologik / language analyzers (UkrainianMorfologikAnalyzer normalization: Analyzer#normalize and initReaderForNormalization, used by the query parsers for multi-term queries)
Priority
Medium, rank 120: The stock Ukrainian analyzer skips its normalization for every multi-term query, so ordinary capitalised or apostrophe words miss silently.
Impact class
silent wrong results
Impact note
Every application that indexes text with the stock UkrainianMorfologikAnalyzer and accepts prefix, wildcard, regexp, range or fuzzy syntax through Lucene's classic, flexible or simple query parser (or calls Analyzer.normalize itself) silently gets too few hits, and for ranges sometimes extra terms, whenever the query term has an upper-case letter, a typographic apostrophe (U+2019, U+02BC, U+2018, backtick, U+00B4), a stress mark (U+0301), a soft hyphen (U+00AD) or ґ, because the index holds only the lower-cased, mapped form; term and phrase queries and the indexed data are unaffected, no setting or scale is needed, every release from 6.2.0 to 10.5.2 and main has it (the classic-parser case symptom from 7.0.0), and by code reading two downstream search servers' Ukrainian analysis plugins expose the stock class unchanged.
Summary
You index Ukrainian text with UkrainianMorfologikAnalyzer, which maps typographic apostrophes to ', removes stress marks and soft hyphens, maps ґ to г and lower-cases every token before lemmatizing, so the document 'м’ясо Lucene' is stored as the terms м'ясо and lucene. A prefix, wildcard, regexp, range or fuzzy query built by a query parser sends its term through Analyzer.normalize, documented to return 'the representation that it would have in the index', but this analyzer overrides neither normalize(String, TokenStream) nor initReaderForNormalization, so normalize returns the text unchanged. As a result 'м’яс*', 'Luc*', 'Luce?e' and '[Lucene TO Lucene]' find nothing while "м'яс*" and 'luc*' find the document, and normalize("Lucene") returns 'Lucene' although the index holds 'lucene'.
Introduced
7c2e7a0fb80a5bf733cf710aee6cbf01d02629eb on branch_6x (first released in 6.2.0) and its master twin e92a38af90d12e51390b4307ccbe0c24ac7b6b4e (first released in 7.0.0): LUCENE-7355 'Add Analyzer#normalize() and use it in query parsers' (GitHub issue apache/lucene#8409, JIRA patch, no PR), which created Analyzer#normalize, normalize(String, TokenStream) and initReaderForNormalization and added overrides to 42 stock analyzers but not to UkrainianMorfologikAnalyzer, which the parent of both commits already contains [read: the two diffs; git log --all -G 'normalize|initReaderForNormalization' on the file returns nothing, so no branch ever had either override]. The analyzer came one week earlier with LUCENE-7287 (6ef174f5273 on master / 4a71e03a32f on branch_6x, 2016-06-21, apache/lucene#8342), with the MappingCharFilter in initReader and the LowerCaseFilter from the start. The master commit also removed the parsers' lowercaseExpandedTerms (MIGRATE.txt), which exposed the case half in the classic parser from 7.0.0; LUCENE-7785 (2017-04-19) and LUCENE-7841 (effd3766e7f, 2017-05-25) later widened the map in initReader only (2016-06-28 (LUCENE-7355, both branches; the analyzer itself 2016-06-21))
First affected version
6.2.0 [exec] (Analyzer.normalize and the simple parser's prefix query fail; with the back-ported two-method fix they pass on 6.2.0 and 6.6.6); the classic-parser case symptom and the ґ symptom start in 7.0.0 [exec] (6.x classic parsers lower-cased expanded terms themselves, and the ґ mapping first shipped in 7.0.0); 6.1.0 has neither the class nor Analyzer.normalize [exec, compile error]
Affected versions
Every release 6.2.0 to 10.5.2 and main ea57360912ed. [exec] the follow-up's portable history probe (-ea, seed DEADBEEF) on 6.2.0, 6.6.6, 7.0.0, 8.11.4, 9.12.3, 10.5.2 and main, every control passing; the finder's JUnit (2 of 2 fail) on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit (3 of 4 fail, its control passes) on main (-ea and -da), 10.5.2 and 9.12.3, and the independent reproduction's cross-version runs on 6.2.0, 6.6.6, 7.0.0 and 8.11.4; the fix back-ported to 7.0.0 makes every bug group pass, and back-ported to 6.2.0 and 6.6.6 the normalize and simple-parser groups. [read] the other 75 release tags 6.2.1 to 10.5.1 in the history clone (the file lower-cases and char-maps and has no normalization override); 8.8.2 to 8.11.3 inferred (not tagged in the history clone, between 8.8.1 [read] and 8.11.4 [exec]). In 6.x the classic parser's apostrophe miss is a limitation of the 6.x parser (the fix does not change it there), not this bug. Upstream apache/lucene main ffa9d816eeb4 (2026-10-26) is unchanged [read]. Not affected: 6.1.0 (API absent) [exec]. Not executed: x86
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 (26 GitHub issue and PR queries on apache/lucene, 4 commit, 4 downstream-repository, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 10 JIRA-archive queries, 6 web queries) plus the finder's earlier run; 36 manual queries (GitHub search including two all-of-GitHub class-name searches, 6 JQL queries over LUCENE and SOLR, a 'Ukrainian' search over six lists with 356 mails whose subjects were all read, 8 downstream-repository queries, 9 web searches, one in Ukrainian); full-text regex scans of 12 JIRA and 13 GitHub issues; the changed-file lists of all 407 open PRs; git history. 32 hits read, none the same bug. The issues that created or changed this analyzer's normalization (#8342 LUCENE-7287, #8836 LUCENE-7785, #8892 LUCENE-7841) and #8409 (LUCENE-7355) never mention normalize() or multi-term queries for this class. Related, not reports: #8482 (LUCENE-7429, DelegatingAnalyzerWrapper skipped by LUCENE-7355, fixed as a bug), #9070 (LUCENE-8022, a user's own analyzer without normalize, resolved Invalid: the analyzer must override normalize), #9234, #8657 and #9141 (CustomAnalyzer and factory normalization bugs, other classes), #8489 (open, wildcard tokenization); downstream generic wildcard items elastic/elasticsearch#84191, #43759 and #121281 and opensearch-project/OpenSearch#20843 (ASCII-only case folding in that engine's own wildcard field) are other code. Not fixed: upstream main ffa9d816eeb4 (2026-10-26) still has only initReader; the only open PR that touches the file (#16911) deletes branch_10x. Gaps: keyword search; forums reached only through general web search; private reports not visible. Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): the repro through gradle in a scratch worktree (stock fails with assertions on and off; with the two-override fix it passes, and so do TestUkrainianAnalyzer, the whole analysis-morfologik test suite with assertions on and off and :lucene:analysis:morfologik:check; the worktree was removed afterwards) and through the standalone JUnit runner on main (-ea, -da) and on the Maven Central jars of 10.5.2 (-ea, -da) and 9.12.3 (-ea) (fail), and on main with the fix as shadow source (pass); the follow-up's portable probe on 6.2.0, 6.6.6, 7.0.0 and 8.11.4 (fail) and 6.1.0 (class absent); upstream main ffa9d816eeb4 read on 2026-10-26 and a95c754ee003 (same code, not fixed); the regexp and range trade-off probe and the option-B variant of testNormalize through the runner on main (2026-10-26). The fix was validated on main only (plus the follow-up's back-ports to 6.2.0, 6.6.6 and 7.0.0).

Lucene and upstream sources

Finding record
  • lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java:38-43 (at ea57360912ed) (class javadoc '@since 6.2.0', final class), :47-64 (NORMALIZER_MAP: U+2019, U+2018, U+02BC, backtick, U+00B4 to ', U+0301 and U+00AD removed, ґ/Ґ to г/Г), :151-154 (initReader: MappingCharFilter), :165-177 (createComponents: StandardTokenizer -> LowerCaseFilter -> StopFilter -> [SetKeywordMarkerFilter] -> MorfologikFilter); no normalize or initReaderForNormalization override
  • lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:122-129 (at ea57360912ed) (normalize(String, TokenStream) default returns the stream as-is), :202-213 (normalize(String, String): 'the representation that it would have in the index', applies 'necessary character-level normalization' through initReaderForNormalization), :279-286 (initReaderForNormalization: 'typically a subset of the CharFilters that are applied in initReader', default returns the reader unchanged)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, (at ea57360912ed) :556 (range bounds), :646, :656 (wildcard chunks), :683 (regexp), :712 (prefix), :729 (fuzzy) call Analyzer.normalize; :610-612, :666-668, :695-697 (stale javadoc 'will not go through the default Analyzer'); lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:573 (fuzzy), :605 (prefix); the flexible WildcardQueryNodeProcessor, TermRangeQueryNodeProcessor, FuzzyQueryNodeProcessor and RegexpQueryNodeProcessor (follow-up the security record sources)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java:48-64 (at ea57360912ed) and lucene/demo/src/java/org/apache/lucene/demo/knn/DemoEmbeddings.java:41-51 (grep over src/java trees: the other analyzers whose chain lower-cases without a normalize(String, TokenStream) override)
  • https://github.com/apache/lucene/issues/8409 (LUCENE-7355, introduces Analyzer#normalize), https://github.com/apache/lucene/issues/8342 (LUCENE-7287, the analyzer), https://github.com/apache/lucene/issues/8892 (LUCENE-7841, ґ to г 'the search will benefit'), https://github.com/apache/lucene/issues/8482 (LUCENE-7429, precedent fixed as a bug), https://github.com/apache/lucene/issues/9070 (LUCENE-8022, the analyzer must override normalize)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java:43 (public final class), :47-64 (NORMALIZER_MAP), :151-154 (initReader), :165-177 (createComponents); no normalize or initReaderForNormalization override
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:122-129, :202-213, :279-286
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:550, :556, :646, :656, :683, :712, :729; lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:573, :605; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/WildcardQueryNodeProcessor.java:66, :76, TermRangeQueryNodeProcessor.java:138-139, FuzzyQueryNodeProcessor.java:59, RegexpQueryNodeProcessor.java:43
Who is affected
  • Lucene main ea57360912ed: lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java:43, :47-64
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:45-60, :122-129, :202-213, :279-286
  • Lucene main ea57360912ed: lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/uk/UkrainianMorfologikAnalyzer.java:47-64
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:610-612, :666-668, :695-697, :683, :712
  • Lucene main ea57360912ed: lucene/analysis/common/src/java/org/apache/lucene/analysis/ru/RussianAnalyzer.java:119-121; lucene/analysis/stempel/src/java/org/apache/lucene/analysis/pl/PolishAnalyzer.java:145-147; lucene/analysis/common/src/java/org/apache/lucene/analysis/th/ThaiAnalyzer.java:112-128; lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseAnalyzer.java:120-133
  • Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1478-1479
  • Lucene main ea57360912ed: lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java:52, :62; lucene/analysis/common/src/java/org/apache/lucene/analysis/fa/PersianAnalyzer.java:147-149, :158-160; lucene/analysis/morfologik/src/java/org/apache/lucene/analysis/morfologik/MorfologikFilter.java:121-135
Suggested fix
  • Lucene main ea57360912ed: lucene/analysis/morfologik/src/test/org/apache/lucene/analysis/uk/TestUkrainianAnalyzer.java:64-69 (testCapsTokenStream)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/AnalyzerWrapper.java:90, :114 (wrapTokenStreamForNormalization, wrapReaderForNormalization)

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

Repro files

About the diagrams

A light, step-by-step picture of the finder's example (one document м’ясо Lucene, the classic parser, м’яс* and Luc*), made in parallel with this doc. Every value on screen comes from an executed probe on main ea57360912ed (assertions on and off, and with the fix shadowed) and 10.5.2, or from the code; the PNG is the summary card.

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.