All findings · Lucene finding · LFV-1086 · doc-index-migration-guide-previous-major-string-minus

The Lucene 10.6.0 documentation front page says "how to migrate code from Lucene 0.x": the build hands the major version to the page template as text, so "10" minus 1 becomes "0"

The Lucene 10.6.0 documentation front page says the Migration Guide covers migrating "from Lucene 0.x" instead of 9.x. The template computes "major version minus 1", but the build now passes the major version as the text "10", and Groovy's "10" - 1 deletes the first "1" (main: "12" becomes "2").

Summary

Priority
Low, rank 1290Documentation only and cosmetic: the generated 10.6.0 documentation front page and every later documentation build name a nonexistent previous version in the descriptive Migration Guide sentence. The link and the guide itself are correct, so it misleads no API use.
Impact
minor
Since
10.6.0 (main since #14862, 2025-07-01; 10.x via #16592, 2026-09-05)introduced 2025-07-01 · about 1 year
Affected
10.6.0 binary release docs, and every documentation build of main, branch_11x and branch_10_6
Status
documented
Found by
non-formal: diff of the shipped 10.5.2 and 10.6.0 front pages during a review of the Java port of the documentation build

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

The finding in 30 seconds

One sentence of the documentation front page names a Lucene version that does not exist. The link next to it and the Migration Guide itself are correct.

Background: how the front page is made

  1. ./gradlew :lucene:documentation:createDocumentationIndex turns lucene/documentation/src/markdown/index.template.md into index.html, the first page of the documentation (docs/index.html in the binary release).
  2. Groovy's SimpleTemplateEngine fills in the template: each ${...} is a Groovy expression over values the build supplies, here project.version and project.majorVersion.
  3. In Groovy, - on a number subtracts, but - on a String removes the first occurrence of the right-hand text: 12 - 1 is 11, "12" - 1 is "2".
Diagram of the bug: the front-page template line 'how to migrate code from Lucene ${project.majorVersion-1}.x' is filled with majorVersion; through 10.5.2 that was the number 10, so 10 - 1 = 9 and the page said 'from Lucene 9.x'; in 10.6.0 it is the text "10", Groovy's "10" - 1 removes the first '1' and gives "0", so the page says 'from Lucene 0.x' (main: "12" gives "2").
The template sentence, the value the build hands it (the text "10" instead of the number 10), and what Groovy makes of "10" - 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You open the documentation front page, docs/index.html of the Lucene 10.6.0 binary release, and read the "Migration Guide" entry.
  2. You expect "What changed in Lucene 10; how to migrate code from Lucene 9.x.", as 10.5.2 says: the page template writes ${project.majorVersion-1}, the major version minus 1.
  3. Instead it says "... how to migrate code from Lucene 0.x." A build of main says "Lucene 12; ... from Lucene 2.x", branch_11x "Lucene 11; ... from Lucene 1.x". The build prints no warning.
  4. Why: since a 2025 build refactor (backported to 10.x in 2026), the major version reaches the template as the text "10", not the number 10. The template engine is Groovy, where "10" - 1 removes the first "1" from the text: "10" becomes "0", "12" becomes "2". With the number, 10 - 1 = 9.

Who is affected

Anyone reading the 10.6.0 documentation front page (in the binary release now; on the website if that page is published from the same build), and every documentation build of main, branch_11x and branch_10_6. Default build, no opt-in, no warning. Only one descriptive sentence is wrong: the link works, and nothing in code, indexes or APIs changes.

Mechanism, terms and numbers459 words

The front page of the 10.6.0 documentation (docs/index.html in the binary release, line 38) reads "What changed in Lucene 10; how to migrate code from Lucene 0.x." It should say 9.x, as 10.5.2 did. The template line is What changed in Lucene ${project.majorVersion}; how to migrate code from Lucene ${project.majorVersion-1}.x. (index.template.md:51-52), evaluated by Groovy's SimpleTemplateEngine. Since #14862 (main, 2025-07-01), and on the 10.x line since the build-infra backport #16592 (2026-09-05), the build's majorVersion is a String (LuceneBuildGlobalsExtension.java:51), and DocumentationConfigPlugin.java:233 binds it unchanged. For a String, Groovy's - removes the first occurrence of the operand's text: "10" - 1 is "0", "12" - 1 is "2". Through 10.5.2 the template called a closure that returned an int, ${project.majorVersion()-1}. Main builds print "Lucene 12; ... from Lucene 2.x.", branch_11x "Lucene 11; ... from Lucene 1.x.". Only this sentence is wrong: the link opens the correct Migration Guide and nothing in code, indexes or APIs changes. Binding Integer.parseInt(buildGlobals.majorVersion) restores "9.x" (main: "11.x"); the rebuilt page changes in that one line only.

The claim

  • The 10.6.0 binary release's docs/index.html, line 38: <li><a href="MIGRATE.html">Migration Guide</a>: What changed in Lucene 10; how to migrate code from Lucene 0.x.</li>. 10.5.2's page has "... from Lucene 9.x." on the same line. The 10.6.0 website copy of the page is not published yet (HTTP 404 on 2026-11-02, while 10_6_0/MIGRATE.html is live).
  • A build of main e1faeba08bde prints "What changed in Lucene 12; how to migrate code from Lucene 2.x."; branch_11x "Lucene 11; ... 1.x."; branch_10_6, the 10.6.1 line, "Lucene 10; ... 0.x.".

The mechanism (main e1faeba08bde)

  1. RegisterBuildGlobalsPlugin.java:149-152 returns the major version as the regex group of the version string, a String; :128 stores it: globals.majorVersion = majorVersion;. LuceneBuildGlobalsExtension.java:50-51: /** Major Lucene version ({@code x} in {@code x.y.z}). */ public String majorVersion;. A Gradle init script on the real build printed majorVersion=12 type=java.lang.String.
  2. DocumentationConfigPlugin.java:227-233 binds it unchanged: Map.entry("majorVersion", buildGlobals.majorVersion) (line 234 at the 10.6.0 tag).
  3. DocumentationConfigPlugin.java:331-336 evaluates the template with new SimpleTemplateEngine(); index.template.md:51-52: What changed in Lucene ${project.majorVersion}; how to migrate code from Lucene ${project.majorVersion-1}.x.
  4. Groovy's String.minus(Object) removes the first "1": String "10" gives 0, "11" gives 1, "12" gives 2; Integer 10 gives 9, Integer 12 gives 11 (executed with Groovy 4.0.33, the version the main build's Gradle 9.8.1 ships, by checkers independently; a cross-model review got the same result for "11" and Integer 11 with Groovy 6.0.0 jars).

Through 10.5.2 the value came from an int closure (build.gradle:71: // "majorVersion" is an integer with just the major version. Compute it.; :77: return m[0][1] as int), and the template called ${project.majorVersion()-1}. The build has no other reader of buildGlobals.majorVersion at e1faeba08bde.

Exposure and likelihood189 words
  • Who meets it: anyone reading the documentation front page of the 10.6.0 binary release, or the website page if it is published from that build; a later 10.6.x or 11.x release built from the current branches before a fix would carry it too. The page is the documentation's entry page.
  • What they get: a sentence naming a version that does not exist ("0.x", "1.x", "2.x"). The link next to it opens the correct guide, so nobody is sent to the wrong one.
  • What does not happen: no effect on code, indexes, APIs, queries or results.
  • Scale: one line of one generated page, but deterministic on every documentation build of the affected branches, and it would repeat for later majors while the value stays a String.
  • How much it matters: little. The link works and readers will likely see "0.x" as a glitch. Still, it is a build-code regression (a type change in a refactor that expected no functional change), not a typo; it is in a released artifact; the fix is one line; and this page is already being fixed for 10.6.1 (#16939, #16941).

Minimal repro

One JUnit test, one method, no index: it reads a built index.html (directory in -Ddocs.dir), takes N from "What changed in Lucene N" and asserts that the sentence names N-1. It fails on the 10.6.0 release page and on a main build, and passes on 10.5.2 and on the fixed build.

TestMigrationGuidePreviousMajor.java (one test class)18 lines
package org.apache.lucene;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMigrationGuidePreviousMajor extends LuceneTestCase {

  // Expected "from Lucene N-1.x"; actual: main "Lucene 12; ... from Lucene 2.x", 10.6.0 "Lucene 10; ... from Lucene 0.x".
  public void testPreviousMajor() throws Exception {
    String index = Files.readString(Path.of(System.getProperty("docs.dir"), "index.html"));
    Matcher m = Pattern.compile("Lucene (\\d+); how to migrate code from\\s+Lucene (\\d+)\\.x").matcher(index);
    assertTrue(m.find());
    assertEquals(Integer.parseInt(m.group(1)) - 1, Integer.parseInt(m.group(2)));
  }
}
Command
cd $LUCENE && cp $REPRO/TestMigrationGuidePreviousMajor.java lucene/core/src/test/org/apache/lucene/ && ./gradlew :lucene:documentation:createDocumentationIndex && ./gradlew :lucene:core:test --tests org.apache.lucene.TestMigrationGuidePreviousMajor -Ptests.seed=DEADBEEF -Ptests.asserts=true "-Ptests.jvmargs=-Ddocs.dir=$PWD/lucene/documentation/build/site"
Expected
The sentence names N-1: "Lucene 12; how to migrate code from Lucene 11.x" on main (10.6.0: "Lucene 10; ... from Lucene 9.x").
Observed
AssertionError: expected:<11> but was:<2> on a main build ("from Lucene 2.x"); expected:<9> but was:<0> on the 10.6.0 release page ("from Lucene 0.x").

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

More files, environment, full output369 words

The test reads only page text, so the Lucene jars, the seed and -ea/-da cannot change the result; it uses no Lucene search or index API, only LuceneTestCase and its assertions, and the package only lets it run in :lucene:core:test. -Ptests.jvmargs passes the property to the forked test JVM; it replaces gradle's default extra test JVM flags, which does not matter here. The file has no Apache license header, so the build's checkLicenses would reject it in the tree.

The simplest demonstration needs no test: grep -n 'migrate code' lucene-10.6.0/docs/index.html prints line 38, "... how to migrate code from Lucene 0.x."; on main, run ./gradlew :lucene:documentation:createDocumentationIndex and grep lucene/documentation/build/site/index.html.

No regression test is suggested. The test needs a built page, so it does not fit gradlew test as it stands, and the fix is one line; it is evidence only. If committers want a guard, it is this same test.

Environment

lucenemain `e1faeba08bde` (12.0.0-SNAPSHOT, Gradle wrapper 9.8.1) and the 10.6.0 release page: fail; 10.5.2 and 9.12.3 release pages and main with the fix: pass
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cd $LUCENE                      # main, branch_11x or branch_10_6
./gradlew :lucene:documentation:createDocumentationIndex
cp $REPRO/TestMigrationGuidePreviousMajor.java lucene/core/src/test/org/apache/lucene/
./gradlew :lucene:core:test --tests org.apache.lucene.TestMigrationGuidePreviousMajor -Ptests.seed=DEADBEEF -Ptests.asserts=true "-Ptests.jvmargs=-Ddocs.dir=$PWD/lucene/documentation/build/site"
# same failure with -Ptests.asserts=false; for the released page: -Ddocs.dir=<unpacked lucene-10.6.0>/docs

Observed output

# gradle on a worktree of main e1faeba08bde after createDocumentationIndex, seed DEADBEEF, -Ptests.asserts=true (identical with false)
> Task :lucene:core:test FAILED
TestMigrationGuidePreviousMajor > testPreviousMajor FAILED
    java.lang.AssertionError: expected:<11> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3D9592C008903CD1]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.TestMigrationGuidePreviousMajor.testPreviousMajor(TestMigrationGuidePreviousMajor.java:16)
:lucene:core:test (FAILURE): 1 test, 1  failure
BUILD FAILED in 51s

# the standalone JUnit runner, seed DEADBEEF
# 10.6.0 release docs/index.html   -ea FAIL expected:<9> but was:<0>    -da FAIL (same)
# main e1faeba08bde build          -ea FAIL expected:<11> but was:<2>   -da FAIL (same)
# 10.5.2 release docs/index.html   -ea PASS                             -da PASS
# main + fix (bind the int)        -ea PASS                             -da PASS

# the page line, main build before and after the fix
38c38
< <li><a href="MIGRATE.html">Migration Guide</a>: What changed in Lucene 12; how to migrate code from Lucene 2.x.</li>
---
> <li><a href="MIGRATE.html">Migration Guide</a>: What changed in Lucene 12; how to migrate code from Lucene 11.x.</li>

Why it looks unintended

  • The template's arithmetic, ${project.majorVersion-1}, exists to name the previous major; every release from 9.0.0 to 10.5.2 printed it right (10.5.2: "from Lucene 9.x").
  • Through 10.5.2 the build documented the value as "an integer with just the major version" (return m[0][1] as int).
  • The String came with #14862, a build refactor whose PR description says "no functional changes are expected".
The full argument300 words
  • The sentence and its arithmetic came with LUCENE-9321 (06df50e759a, "Port markdown task to Gradle (#1477)", Uwe Schindler, 2020-05-17, first released in 9.0.0), which added the template line ${project.majorVersion()-1} together with the int closure (// "majorVersion" is an integer with just the major version., return m[0][1] as int). Before that, the Ant build's index.xsl hard-coded the sentence for each major (since LUCENE-4008, a20aa3e0c99, 2012); LUCENE-9577 (2020-10-09) only moved the template.
  • The build's own contract: 10.5.2 build.gradle:71: // "majorVersion" is an integer with just the major version. Compute it.; :77: return m[0][1] as int.
  • History of the type (four steps; commit-level builds): through 10.5.2 majorVersion was a closure returning an int, called as ${project.majorVersion()-1}. #14764 (42123980c25, 2025-06-14) evaluated the closure eagerly, so it became an int property, and switched the template to the property form; harmless: a build of 6071569926f (#14879, the parent of #14862, which already has #14764's template and eager int) prints "Lucene 11; ... from Lucene 10.x.". #14862 (a7fc3d0ae83, 2025-07-01, "Gradle plugins to java, batch 1" by dweiss; PR description: "no functional changes are expected") introduced public String majorVersion; its build prints "... from Lucene 1.x.". #15350 (a0bd3c5f3c6) carried the String into the Java documentation task, and #16592 (f6fce7e42c5, 2026-09-05, a broad backport of main's build-infra changes) brought both to the 10.x line (parent "9.x", commit "0.x"). The searches found no discussion of the type or the sentence in these PRs.
  • What the link covers: 10.6.0's MIGRATE.md:102 is "## Migration from Lucene 9.x to Lucene 10.0".
  • No test or release check reads the sentence: no test in the main tree reads it, and the release smoke test only checks that the MIGRATE and CHANGES files exist.
The devil's-advocate check145 words

Strongest case for leaving it (the intended-behaviour devil's advocate and the cross-model lenses): String is the declared type of the build global (javadoc "Major Lucene version ({@code x} in {@code x.y.z})"), Groovy's String.minus does exactly what it is documented to do, and the sentence is cosmetic: the link works.

Why it fails: the consumer is Lucene's own template, whose arithmetic only makes sense on a number and was written when the build documented the value as an integer; the String came from a refactor that expected no functional change; and "from Lucene 0.x" has no intended reading. Being cosmetic does not make it intended; it keeps the priority Low, while the false version still ships in a released artifact and comes from a build-code regression. An independent reviewer from another model family (OpenAI GPT-5.6) rated the "intended" case weak in both of its lenses.

Suggested fix

Recommended Bind the major version as an int (validated)

Bind the major version as a number in DocumentationConfigPlugin.java:233: Map.entry("majorVersion", Integer.parseInt(buildGlobals.majorVersion)). Validated on main: the rebuilt page changes only "2.x" to "11.x", the test passes with assertions on and off, and the build-infra checks (format, ecj, forbidden APIs, licenses) pass.

@@ -230,7 +230,7 @@ public class DocumentationConfigPlugin extends LuceneGradlePlugin {
                   "project",
                   Map.ofEntries(
                       Map.entry("version", project.getVersion().toString()),
-                      Map.entry("majorVersion", buildGlobals.majorVersion)));
+                      Map.entry("majorVersion", Integer.parseInt(buildGlobals.majorVersion))));
 
           task.getBinding()
               .put(
All options and trade-offs3 options ยท 380 words

One line in the documentation plugin (line 234 on branch_10_6). LFV-1087 is a second defect of the same front page (it lists benchmark-jmh javadocs that no build renders), and #16941 fixed a third one, the File Formats link, for 10.6.1. On main the fixed text "from Lucene 11.x" points at a MIGRATE.md section that does not exist yet (main's top section is "Migration from Lucene 10.x to Lucene 11.0"), which is normal before a major release.

Bind the major version as an int (validated) (recommended)

DocumentationConfigPlugin.java:233 on main and branch_11x, :234 on branch_10_6 and the 10.6.0 tag.

@@ -230,7 +230,7 @@ public class DocumentationConfigPlugin extends LuceneGradlePlugin {
                   "project",
                   Map.ofEntries(
                       Map.entry("version", project.getVersion().toString()),
-                      Map.entry("majorVersion", buildGlobals.majorVersion)));
+                      Map.entry("majorVersion", Integer.parseInt(buildGlobals.majorVersion))));
 
           task.getBinding()
               .put(

Trade-offs

Smallest change; the build global keeps its String type. Integer.parseInt cannot fail for any real version: the \d+ regex group guarantees digits, and only a major above 2147483647 would overflow. Not applied to branch_10_6 here (same line text, one line lower).

What the checks validated

In a scratch worktree of main e1faeba08bde, OpenJDK 25.0.3, Gradle wrapper 9.8.1: createDocumentationIndex rebuilds the page, which differs from the stock build in line 38 only ("2.x" -> "11.x") and is byte-identical to the finder's fixed page; the minimal test through gradle passes with -Ptests.asserts=true and false (it fails on the stock page); ./gradlew :build-tools:build-infra-shadow:check, which re-applies Lucene's checks to the build-infra sources, passes (checkGoogleJavaFormat, ecjLint, forbiddenApisMain, checkLicenses, validatePlugins). Neither that project nor the documentation module has tests, so these are the closest checks. The worktree was removed afterwards.

Convert in the template (validated: build and test)

index.template.md:52 becomes Lucene ${(project.majorVersion as int) - 1}.x.

Trade-offs

Fixes only this expression; any later template arithmetic on majorVersion hits the same trap, and the template is harder to read.

What the checks validated

Same scratch worktree, fix A reverted: the rebuilt page changes line 38 to "11.x" and the minimal test passes (-ea).

Make the build global an int again (not run)

Declare public int majorVersion in LuceneBuildGlobalsExtension.java:51 and parse it once in RegisterBuildGlobalsPlugin.

Trade-offs

Restores the 10.5.2 contract ("an integer") for every consumer, but changes a public field of the build's extension and touches two files; at e1faeba08bde the documentation binding is its only reader.

How it was found

Non-formal. A finder compared the 10.5.2 Groovy documentation build item by item with its Java port and diffed the shipped 10.5.2 and 10.6.0 front pages: "from Lucene 9.x" against "from Lucene 0.x". A Gradle init script on the real build then printed the template's value: majorVersion=12, type java.lang.String.

The method in full95 words
  • Lead: the hunt unit started from another defect on the same page (the API Javadocs list links benchmark-jmh, now LFV-1087) and swept the rest of the Groovy-to-Java port of the documentation build (gradle/documentation/*.gradle at 10.5.2 against DocumentationConfigPlugin.java, 15 items).
  • Differential: the shipped 10.5.2 and 10.6.0 front pages differ in the Migration Guide sentence ("9.x" against "0.x").
  • Build model: a Gradle init script printed the template binding on main, branch_11x and branch_10_6 (String every time) and on main with the fix (Integer).
  • No model checker, SMT solver or prover was involved.

Prior reports, history and checks

Prior reports

No public report found (2026-11-02: 49 scripted queries over GitHub, the mailing lists and the JIRA archive, 36 manual GitHub searches, all 410 open PRs' files, the 35 mails of the 10.6.0 release threads; rechecked the same day). A screenshot in #16939, a different link on the same page, happens to show the "0.x" line; nobody mentions it.

Searches2 searches

Related, not this bug: #16939 / #16941 (the File Formats link on the same page, fixed for 10.6.1 on 2026-10-29; the #16939 screenshot shows the "0.x" sentence, and neither its text, its 7 comments nor #16941 mention it); Uwe Schindler's 10.6.0 RC1 vote mail, which reviewed the same page and reported only the link; #14862, #15350 and #16592 (the introducing and carrying changes, no discussion of the type or the sentence); #15613 (open, touches the plugin, codec regex only).

Recheck on 2026-11-02: no apache/lucene issue or PR created on 2026-11-02 matches "migrate code from", "majorVersion" or "Migration Guide" (a fact-check query from 2026-11-01 found none either); the binding line is unchanged on main, branch_11x and branch_10_6, and the last commit touching the plugin on all three is #16941.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), apache/lucene-site, Solr, Elasticsearch, OpenSearch; dev@, java-user@, issues@, commits@; the web2026-11-02scripted prior-report search (20 issue/PR, 3 commit, 2 downstream, 20 list, 4 JIRA queries; 542 hits, 0 high); 36 manual GitHub searches (symptom strings, code terms, last 48 h); file lists of all 410 open PRs; commits on the four fix-site files on main, branch_11x, branch_10_6, branch_10x; all 35 mails of the 10.6.0 release threads; 5 web searchesnothing reporting it; nearest #16939, #16941, the RC1 vote mail, #14862, #15350, #16592, #15613
apache/lucene upstream (GitHub API) and the project website2026-11-02last commits on the plugin, the template and the globals class on main, branch_11x, branch_10_6; the binding line on each head; issue/PR searches since 2026-11-01; HTTP status of 10_6_0/index.html and 10_6_0/MIGRATE.htmlstill present on all three branches; no issue or PR; the 10.6.0 site front page is not published yet (404)

History

Introduced on main by #14862 (a7fc3d0ae83, 2025-07-01), which made majorVersion a String; reached the 10.x line with the backport #16592 (f6fce7e42c5, 2026-09-05); first released in 10.6.0. Every release from 9.0.0 to 10.5.2 is correct, and commit-level builds flip from correct to wrong at exactly these two commits.

Versions tested7 rows

The template line and the int closure behind it date from LUCENE-9321 (06df50e759a, 2020-05-17, first released in 9.0.0); the Ant build hard-coded the sentence before (index.xsl, since LUCENE-4008, 2012). #14764 (42123980c25, 2025-06-14) made the int eager and switched the template to ${project.majorVersion-1} while the value was still an int: harmless (a build of 6071569926f, #14879, the parent of #14862, which already has #14764's template, prints "Lucene 11; ... 10.x."). #14862 made it a String on main; #15350 (a0bd3c5f3c6, 2025-10-22) carried it into the Java documentation task; #16592 brought both to branch_10x. git tag --contains f6fce7e42c5 lists only releases/lucene/10.6.0. The main pair was built on the same Gradle 8.14.6, so the commit is the only difference; #16592 itself moves 10.x from Gradle 8.14 to 9.7, so that pair used two Gradle versions, and both logs print the bound value and type.

Versions and branches executed, with results
Version or commitVerdictResult
10.6.0 (4ae946e5a9b, released 2026-10-29) affectedbinary release docs/index.html line 38: "Lucene 10; ... from Lucene 0.x."; the test fails with -ea and -da
main e1faeba08bde affected"Lucene 12; ... from Lucene 2.x."; the test fails through gradle and the standalone JUnit runner, assertions on and off
branch_11x da3ea39fa59, branch_10_6 91eaab4fb29 affected"Lucene 11; ... 1.x." and "Lucene 10; ... 0.x."; the test fails
main a7fc3d0ae83 (#14862) vs parent 6071569926f flips here (vs parent)String "11": "... 1.x."; parent, Integer 11: "... 10.x."
branch_10x f6fce7e42c5 (#16592) vs parent 260a5c772c4 flips here (vs parent)String "10": "... 0.x."; parent, Integer 10: "... 9.x."
9.0.0 to 10.5.2 not affectedint closure at every tag; the 9.12.3 and 10.5.2 release pages pass ("8.x", "9.x")
main + fix (bind the int) not affected"Lucene 12; ... from Lucene 11.x."; the test passes, build-infra checks pass

Independent checks

Confirmed (high) by an adjudication after an independent reproducer, cross-model reviews (OpenAI GPT-5.6) and a devil's advocate all called it real; a credibility audit agreed, at Low. Re-run for this doc: a Gradle build of main, the test through gradle (fails), and both fixes (pass).

Checks and verdicts362 words

Every verifier ran a failing check on a stock page and a passing one on 10.5.2 or a fixed page. Both cross-model runs used the hunt's older checkout ea57360912ed (11.0.0-SNAPSHOT, same binding, field and template lines) and could not run Gradle, and the refute lens ran its Groovy check on the Groovy 6.0.0 jars named in gradle/libs.versions.toml, not on the Gradle-bundled Groovy 4.0.33 the documentation build uses; the e1faeba08bde executions come from the finder, the independent reproduction, the intended-behaviour check, the adjudication, the registration check and this doc. The 10.5.2 and 9.12.3 controls were read from the sha512-verified release archives (finder, the version-history runs) and from the website copies (by the independent reproducer); both give the same sentence.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (sibling sweep, page differential, build model) reproduced (documentation)The test fails on the 10.6.0 release page and on main, branch_11x and branch_10_6 builds, passes on 10.5.2 and the fixed build; the init script prints String (Integer with the fix).
Independent reproducer realOwn test, own Gradle build of main and own sha512-verified 10.6.0 page: fail -ea/-da; 10.5.2, 9.12.3 and the Integer build pass; own Groovy check.
Cross-model adversary, intended lens (OpenAI GPT-5.6, executed) real (high)Own tests on the template and the supplied pages; the "String is the declared type" defence rated weak.
Cross-model adversary, refute lens (OpenAI GPT-5.6, executed) real (high)Ran the live template with "11" and got 1 instead of 10 (-ea, -da, a second seed; Groovy 6.0.0 jars); the Integer control passes.
Intended-behaviour devil's advocate realThe strongest "intended" case fails; own Groovy run and own sha512-verified 10.6.0 page agree.
Adjudication confirmed (high)4 of 4 runs fail as expected (main and 10.6.0 pages, -ea/-da); own Groovy check.
Registration check and credibility audit confirmed (Low)Fresh seeds 5EED0503 and A0D17004: the 10.6.0 and main pages fail, the fixed page passes; not fixed upstream.
This doc's runs (Gradle build, gradle test, fix validation) reproduced; fix validatedThe stock main build fails the test with assertions on and off; fix A passes it and the build-infra checks; fix B passes it.

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

Finding record

LFV-1086, snapshot of 2026-11-06

Registry title
The 10.6.0 documentation front page says the Migration Guide covers migrating 'from Lucene 0.x' (main builds: 'Lucene 12; ... from Lucene 2.x'), because the build binds majorVersion as a String and the template's ${project.majorVersion-1} then deletes the first '1' character instead of subtracting (documentation build defect)
Area
build-tools / documentation build (DocumentationConfigPlugin createDocumentationIndex template binding; lucene/documentation index.template.md)
Priority
Low, rank 1290: Documentation only and cosmetic: the generated 10.6.0 documentation front page and every later documentation build name a nonexistent previous version in the descriptive Migration Guide sentence. The link and the guide itself are correct, so it misleads no API use.
Impact class
minor
Impact note
Documentation only, with no code, index or API effect: every reader of the documentation front page (docs/index.html) of the 10.6.0 binary release, and of every documentation build of main, branch_11x and branch_10_6, reads a Migration Guide description that names a nonexistent previous version ('0.x' for 10, '1.x' for 11, '2.x' for 12); the standard build prints no warning, the MIGRATE.html link and the guide itself are correct, and the 10.6.0 site page, still unpublished on 2026-11-02, will carry the same text once published.
Summary
You open the documentation front page of Lucene 10.6.0 (docs/index.html) and read the Migration Guide entry. You expect 'What changed in Lucene 10; how to migrate code from Lucene 9.x.', as 10.5.2 says, because the template computes the previous major as ${project.majorVersion-1}; instead it says 'how to migrate code from Lucene 0.x.' (a main build says 'Lucene 12; ... from Lucene 2.x.'). Since the build's version globals became Java (#14862 on main, backported to 10.x in #16592), the template gets majorVersion as the String '10', and Groovy's String minus 1 deletes the first '1' character instead of subtracting; binding the value as an int restores 'from Lucene 9.x' (main: '11.x').
Introduced
a7fc3d0ae8314ebf1236eea3076096b836d19ca8 (apache/lucene#14862 'Gradle plugins to java, batch 1' by dweiss, main; PR text 'no functional changes are expected'): it replaced the build's int majorVersion ('"majorVersion" is an integer with just the major version', 'm[0][1] as int') with LuceneBuildGlobalsExtension's 'public String majorVersion' [read: git show; exec: commit-level Gradle builds, its parent 6071569926f renders 'Lucene 11; ... from Lucene 10.x.' (Integer 11) and a7fc3d0ae83 renders '... from Lucene 1.x.' (String "11"), on the same Gradle 8.14.6]. a0bd3c5f3c6 (#15350, 2025-10-22) carried the same String into the Java documentation task (DocumentationConfigPlugin: Map.entry("majorVersion", buildGlobals.majorVersion)). On the 10.x line it arrived with f6fce7e42c5 (#16592 'Build infra modernization (backport)' by dweiss, branch_10x) [exec: parent 260a5c772c4 renders '9.x', f6fce7e42c5 renders '0.x']. The template line and its int closure date from LUCENE-9321 (06df50e759a, 2020-05-17, first in 9.0.0; LUCENE-9577 only moved the template) and were correct until then; #14764 (42123980c25, 2025-06-14) changed the template to ${project.majorVersion-1} while the value was still an int, which was harmless [exec at 6071569926f]. A regression of the build's value type, not a day-one bug (2025-07-01 on main (a7fc3d0ae83, author date = commit date 2025-07-01T11:34:44+02:00); 2026-09-05 on branch_10x (f6fce7e42c5, author date = commit date 2026-09-05T11:48:44+02:00))
First affected version
10.6.0 [exec] (released 2026-10-29; the only release tag that contains f6fce7e42c5); last unaffected 10.5.2 [exec]
Affected versions
10.6.0 (released 2026-10-29, tag releases/lucene/10.6.0 -> 4ae946e5a9b) [exec: the sha512-verified binary distribution's docs/index.html line 38 reads 'What changed in Lucene 10; how to migrate code from Lucene 0.x.'; the test fails with -ea and -da (the finder, the independent reproduction against 10.6.0, the adjudication and the registration check); the intended-behaviour check checked the page text independently]; unreleased main since a7fc3d0ae83 [exec: a Gradle createDocumentationIndex build of e1faeba08bd reads 'Lucene 12; ... from Lucene 2.x.' (finder and the independent reproduction); upstream main 254ce48660d read: binding, field and template unchanged], branch_11x [exec: da3ea39fa59 build, 'Lucene 11; ... from Lucene 1.x.'], branch_10_6, the 10.6.1 line [exec: 91eaab4fb29 build, 'Lucene 10; ... from Lucene 0.x.'] and branch_10x since f6fce7e42c5 [exec; the branch is decommissioned by #16911]. Not affected: every release 9.0.0 to 10.5.2 [read: ${project.majorVersion()-1} with the int closure at every tag; exec: the 9.12.3 and 10.5.2 release pages pass]. The 10.6.0 site page (lucene.apache.org/core/10_6_0/index.html) was not yet published on 2026-11-02 (HTTP 404, while 10_6_0/MIGRATE.html is live), so today the text ships in the binary distribution's docs/ and reaches the site when the page is published
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check, code reading
Upstream and prior reports
none found (searched 2026-11-02; the finder's searches and those of the independent reproduction and the intended-behaviour check agree; rechecked the same day): a scripted prior-report search (20 GitHub issue and PR queries, 3 commit queries, 2 downstream-repository queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries; 542 hits, 0 high, 12 medium, all term-overlap noise), 36 manual GitHub searches on apache/lucene, apache/lucene-site, apache/solr, Elasticsearch and OpenSearch (symptom strings, code terms and the last 48 h), the file lists of all 410 open apache/lucene PRs, the upstream commits touching the four fix-site files on main, branch_11x, branch_10_6 and branch_10x (author and committer dates compared), all 35 mails of the 10.6.0 release threads on dev@, and 5 web searches. Nearest items, not reports of this bug: #16939 (a different defect on the same page, the File Formats link from the codec regex, fixed by #16941 on main, branch_11x and branch_10_6 on 2026-10-29), whose issue-body screenshot happens to show the '0.x' sentence with no comment on it; Uwe Schindler's 10.6.0 RC1 vote mail on dev@, which reviewed the same front page and reported only the link; #14862, #15350 and #16592 (the introducing and carrying changes, with no discussion of the type or the sentence). Not fixed upstream: the last commit touching DocumentationConfigPlugin.java on main, branch_11x and branch_10_6 is #16941 (codec regex only, authored 2026-10-29); index.template.md and LuceneBuildGlobalsExtension.java are unchanged since the porting commits; the only open PR touching DocumentationConfigPlugin.java (#15613) changes only the codec regex. Recheck (2026-11-02): no apache/lucene issue or PR created since late on 2026-11-01, and the two items updated in the hour before the recheck (#16877 release wizard, #16700 QueryRescorer) are unrelated
Lucene commit tested
Main e1faeba08bde (12.0.0-SNAPSHOT, Gradle wrapper 9.8.1): createDocumentationIndex built in a scratch worktree for this doc; the minimal test through gradle fails with assertions on and off (expected:<11> but was:<2>); with the one-line fix the page changes only in line 38 ("2.x" -> "11.x"), the test passes both ways and :build-tools:build-infra-shadow:check passes; a template-side fix also passes. The 10.6.0 release page (sha512-verified binary archive): fails with -ea and -da (expected:<9> but was:<0>). The 10.5.2 and 9.12.3 release pages pass (read from the sha512-verified release archives by the hunt and its follow-up, and from the website copies by an independent reproducer). Builds of branch_11x da3ea39fa59 and branch_10_6 91eaab4fb29, and commit-level builds of #14862 and #16592 and their parents, by the hunt and its follow-up. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • Lucene main e1faeba08bde build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/documentation/DocumentationConfigPlugin.java:227-233 (binding 'project' = Map.ofEntries(version, Map.entry("majorVersion", buildGlobals.majorVersion)))
  • Lucene main e1faeba08bde build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/globals/LuceneBuildGlobalsExtension.java:50-51 ('Major Lucene version'; public String majorVersion)
  • Lucene main e1faeba08bde build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/globals/RegisterBuildGlobalsPlugin.java:128 (globals.majorVersion = majorVersion), :149-152 (getMajorVersion returns the regex group, a String)
  • lucene/documentation/src/markdown/index.template.md:51-52 (at e1faeba08bde) ('What changed in Lucene ${project.majorVersion}; how to migrate code from Lucene ${project.majorVersion-1}.x.')
The finding in 30 seconds
  • a Lucene main checkout (e1faeba08bde) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/globals/RegisterBuildGlobalsPlugin.java:40, 56, 128, 149-152
  • a Lucene main checkout (e1faeba08bde) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/globals/LuceneBuildGlobalsExtension.java:50-51
  • a Lucene main checkout (e1faeba08bde) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/documentation/DocumentationConfigPlugin.java:30, 227-233, 331-336
  • a Lucene main checkout (e1faeba08bde) lucene/documentation/src/markdown/index.template.md:51-52
  • a Lucene clone releases/lucene/10.6.0 (4ae946e5a9b) DocumentationConfigPlugin.java:234, index.template.md:51-52; releases/lucene/10.5.2 (145d89cd30c) build.gradle:71-77, index.template.md:51-52
Minimal repro
  • a Lucene main checkout gradle/wrapper/gradle-wrapper.properties:4 (Gradle 9.8.1)
Why it looks unintended
  • a Lucene clone releases/lucene/10.5.2 build.gradle:71-77; releases/lucene/10.6.0 lucene/MIGRATE.md:102
Suggested fix
  • a Lucene main checkout build-tools/build-infra-shadow/build.gradle (redirects its sources to build-infra so the checks apply); lucene/MIGRATE.md:20
History
  • a Lucene clone and the full-history clone: git show 06df50e759a, a20aa3e0c99, 6071569926f, 42123980c25, a7fc3d0ae83, a0bd3c5f3c6, f6fce7e42c5; git tag --contains f6fce7e42c5
Diagrams
  • a Lucene main checkout (e1faeba08bde) lucene/documentation/src/markdown/index.template.md:51-52; build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/documentation/DocumentationConfigPlugin.java:233

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

Repro files

About the diagrams

Made from the template line, the binding, the shipped 10.5.2 and 10.6.0 front pages and the Groovy check.

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.