You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by dw...@apache.org on 2022/01/05 19:42:42 UTC

[lucene] branch main updated: LUCENE-10328: Module path for compiling and running tests is wrong (#571)

This is an automated email from the ASF dual-hosted git repository.

dweiss pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/main by this push:
     new ff547e7  LUCENE-10328: Module path for compiling and running tests is wrong (#571)
ff547e7 is described below

commit ff547e7bbdc78d6869b6f47d828aa6452664ce58
Author: Dawid Weiss <da...@carrotsearch.com>
AuthorDate: Wed Jan 5 20:42:02 2022 +0100

    LUCENE-10328: Module path for compiling and running tests is wrong (#571)
---
 gradle/documentation/render-javadoc.gradle         |  29 +-
 gradle/java/modules.gradle                         | 585 +++++++++++++++------
 gradle/maven/publications.gradle                   |   4 +-
 gradle/validation/check-environment.gradle         |  10 +-
 gradle/validation/ecj-lint.gradle                  |  49 +-
 gradle/validation/validate-source-patterns.gradle  |  14 +-
 lucene/analysis/common/build.gradle                |   2 +-
 lucene/analysis/icu/build.gradle                   |   2 +-
 lucene/analysis/kuromoji/build.gradle              |   2 +-
 lucene/analysis/morfologik.tests/build.gradle      |   5 +-
 .../morfologik.tests/src/test/module-info.java     |   2 +-
 .../morfologik/tests/TestMorfologikAnalyzer.java   |   8 +-
 lucene/analysis/morfologik/build.gradle            |   2 +-
 lucene/analysis/nori/build.gradle                  |   4 +-
 lucene/analysis/opennlp/build.gradle               |   2 +-
 lucene/analysis/phonetic/build.gradle              |   2 +-
 lucene/analysis/smartcn/build.gradle               |   2 +-
 lucene/analysis/stempel/build.gradle               |   2 +-
 lucene/backward-codecs/build.gradle                |   2 +-
 lucene/benchmark/build.gradle                      |   9 +-
 lucene/classification/build.gradle                 |   6 +-
 lucene/codecs/build.gradle                         |   2 +-
 .../lucene90/{ => tests}/MockTermStateFactory.java |  13 +-
 .../codecs/uniformsplit/TestBlockWriter.java       |   2 +-
 .../sharedterms/TestSTBlockReader.java             |   2 +-
 lucene/core.tests/build.gradle                     |   5 +-
 .../src/java/module-info.java}                     |  14 +-
 .../lucene/core/tests/main/EmptyReference.java}    |  13 +-
 .../lucene/core/tests/main/package-info.java}      |  10 +-
 lucene/core.tests/src/java/overview.html           |  26 +
 lucene/core.tests/src/test/module-info.java        |   3 +-
 .../org/apache/lucene/core/tests/TestMMap.java     |   7 +-
 .../core/tests/TestModuleResourceLoader.java       |   9 +-
 .../core/tests/TestRuntimeDependenciesSane.java    |  58 ++
 lucene/core/build.gradle                           |   4 +-
 lucene/demo/build.gradle                           |   2 +-
 lucene/expressions/build.gradle                    |   2 +-
 lucene/facet/build.gradle                          |   7 +-
 lucene/grouping/build.gradle                       |   2 +-
 lucene/highlighter/build.gradle                    |   6 +-
 lucene/join/build.gradle                           |   2 +-
 lucene/luke/build.gradle                           |   2 +-
 lucene/memory/build.gradle                         |   4 +-
 lucene/misc/build.gradle                           |   2 +-
 lucene/monitor/build.gradle                        |   4 +-
 lucene/queries/build.gradle                        |   4 +-
 lucene/queryparser/build.gradle                    |   2 +-
 lucene/replicator/build.gradle                     |  10 +-
 lucene/sandbox/build.gradle                        |   2 +-
 lucene/spatial-extras/build.gradle                 |  20 +-
 .../lucene/spatial/spatial4j/TestGeo3dRpt.java     |   2 +-
 .../build.gradle                                   |   7 +-
 .../tests}/RandomGeo3dShapeGenerator.java          |  56 +-
 .../lucene/spatial3d/tests/package-info.java}      |  10 +-
 .../spatial-test-fixtures/src/java/overview.html   |  24 +
 lucene/spatial3d/build.gradle                      |  10 +-
 .../spatial3d/geom/GeoBaseCompositeShape.java      |   6 +
 .../lucene/spatial3d/geom/TestGeoExactCircle.java  |   3 +-
 .../spatial3d/geom/TestRandomBinaryCodec.java      |   2 +-
 .../spatial3d/geom/TestRandomGeoPolygon.java       |   2 +-
 .../geom/TestRandomGeoShapeRelationship.java       |   2 +-
 .../lucene/spatial3d/geom/TestRandomPlane.java     |   2 +-
 lucene/suggest/build.gradle                        |   2 +-
 lucene/test-framework/src/java/module-info.java    |   6 +-
 settings.gradle                                    |   1 +
 versions.lock                                      |   2 +-
 66 files changed, 753 insertions(+), 366 deletions(-)

diff --git a/gradle/documentation/render-javadoc.gradle b/gradle/documentation/render-javadoc.gradle
index 7bedb0c..139e151 100644
--- a/gradle/documentation/render-javadoc.gradle
+++ b/gradle/documentation/render-javadoc.gradle
@@ -57,7 +57,7 @@ allprojects {
       outputDir = project.javadoc.destinationDir
     }
 
-    if (project.path == ':lucene:luke' || project.path.endsWith(".tests")) {
+    if (project.path == ':lucene:luke' || !(project in rootProject.ext.mavenProjects)) {
       // These projects are not part of the public API so we don't render their javadocs
       // as part of the site's creation. A side-effect of this is that javadocs would not
       // be linted for these projects. To avoid this, we connect the regular javadoc task
@@ -165,26 +165,6 @@ configure(project(":lucene:test-framework")) {
   project.tasks.withType(RenderJavadocTask) {
     // TODO: fix missing javadocs
     javadocMissingLevel = "class"
-    // TODO: clean up split packages
-    javadocMissingIgnore = [
-        "org.apache.lucene.analysis",
-        "org.apache.lucene.analysis.standard",
-        "org.apache.lucene.codecs",
-        "org.apache.lucene.codecs.blockterms",
-        "org.apache.lucene.codecs.bloom",
-        "org.apache.lucene.codecs.compressing",
-        "org.apache.lucene.codecs.uniformsplit",
-        "org.apache.lucene.codecs.uniformsplit.sharedterms",
-        "org.apache.lucene.geo",
-        "org.apache.lucene.index",
-        "org.apache.lucene.search",
-        "org.apache.lucene.search.similarities",
-        "org.apache.lucene.search.spans",
-        "org.apache.lucene.store",
-        "org.apache.lucene.util",
-        "org.apache.lucene.util.automaton",
-        "org.apache.lucene.util.fst"
-    ]
   }
 }
 
@@ -195,6 +175,13 @@ configure(project(":lucene:sandbox")) {
   }
 }
 
+configure(project(":lucene:spatial-test-fixtures")) {
+  project.tasks.withType(RenderJavadocTask) {
+    // TODO: fix missing javadocs
+    javadocMissingLevel = "class"
+  }
+}
+
 configure(project(":lucene:misc")) {
   project.tasks.withType(RenderJavadocTask) {
     // TODO: fix missing javadocs
diff --git a/gradle/java/modules.gradle b/gradle/java/modules.gradle
index 5e334ab..fa75116 100644
--- a/gradle/java/modules.gradle
+++ b/gradle/java/modules.gradle
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import java.nio.file.Path
+
 // Configure miscellaneous aspects required for supporting the java module system layer.
 
 // Debugging utilities.
@@ -27,189 +29,162 @@ allprojects {
       modularity.inferModulePath.set(false)
     }
 
-    // Map convention configuration names to "modular" corresponding configurations.
-    Closure<String> moduleConfigurationNameFor = { String configurationName ->
-      return "module" + configurationName.capitalize().replace("Classpath", "Path")
-    }
-
-    //
-    // For each source set, create explicit configurations for declaring modular dependencies.
-    // These "modular" configurations correspond 1:1 to Gradle's conventions but have a 'module' prefix
-    // and a capitalized remaining part of the conventional name. For example, an 'api' configuration in
-    // the main source set would have a corresponding 'moduleApi' configuration for declaring modular
-    // dependencies.
-    //
-    // Gradle's java plugin "convention" configurations extend from their modular counterparts
-    // so all dependencies end up on classpath by default for backward compatibility with other
-    // tasks and gradle infrastructure.
     //
-    // At the same time, we also know which dependencies (and their transitive graph of dependencies!)
-    // should be placed on module-path only.
-    //
-    // Note that an explicit configuration of modular dependencies also opens up the possibility of automatically
-    // validating whether the dependency configuration for a gradle project is consistent with the information in
-    // the module-info descriptor because there is a (nearly?) direct correspondence between the two:
-    //
-    // moduleApi            - 'requires transitive'
-    // moduleImplementation - 'requires'
-    // moduleCompileOnly    - 'requires static'
+    // Configure modular extensions for each source set.
     //
     project.sourceSets.all { SourceSet sourceSet ->
-      ConfigurationContainer configurations = project.configurations
-
-      // Create modular configurations for convention configurations.
-      Closure<Configuration> createModuleConfigurationForConvention = { String configurationName ->
-        Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
-        Configuration moduleConfiguration = configurations.maybeCreate(moduleConfigurationNameFor(configurationName))
-        moduleConfiguration.canBeConsumed(false)
-        moduleConfiguration.canBeResolved(false)
-        conventionConfiguration.extendsFrom(moduleConfiguration)
-
-        project.logger.info("Created module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
-        return moduleConfiguration
-      }
-
-      Configuration moduleApi = createModuleConfigurationForConvention(sourceSet.apiConfigurationName)
-      Configuration moduleImplementation = createModuleConfigurationForConvention(sourceSet.implementationConfigurationName)
-      moduleImplementation.extendsFrom(moduleApi)
-      Configuration moduleRuntimeOnly = createModuleConfigurationForConvention(sourceSet.runtimeOnlyConfigurationName)
-      Configuration moduleCompileOnly = createModuleConfigurationForConvention(sourceSet.compileOnlyConfigurationName)
-      // sourceSet.compileOnlyApiConfigurationName  // This seems like a very esoteric use case, leave out.
-
-      // Set up compilation module path configuration combining corresponding convention configurations.
-      Closure<Configuration> createResolvableModuleConfiguration = { String configurationName ->
-        Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
-        Configuration moduleConfiguration = configurations.maybeCreate(
-            moduleConfigurationNameFor(conventionConfiguration.name))
-        moduleConfiguration.canBeConsumed(false)
-        moduleConfiguration.canBeResolved(true)
-        moduleConfiguration.attributes {
-          // Prefer class folders over JARs. The exception is made for tests projects which require a composition
-          // of classes and resources, otherwise split into two folders.
-          if (project.name.endsWith(".tests")) {
-            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, LibraryElements.JAR))
-          } else {
-            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, LibraryElements.CLASSES))
-          }
-        }
-
-        project.logger.info("Created resolvable module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
-        return moduleConfiguration
-      }
-
-      Configuration compileModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.compileClasspathConfigurationName)
-      compileModulePathConfiguration.extendsFrom(moduleCompileOnly, moduleImplementation)
-
-      Configuration runtimeModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.runtimeClasspathConfigurationName)
-      runtimeModulePathConfiguration.extendsFrom(moduleRuntimeOnly, moduleImplementation)
-
       // Create and register a source set extension for manipulating classpath/ module-path
-      ModularPathsExtension modularPaths = new ModularPathsExtension(project, sourceSet,
-          compileModulePathConfiguration,
-          runtimeModulePathConfiguration)
+      ModularPathsExtension modularPaths = new ModularPathsExtension(project, sourceSet)
       sourceSet.extensions.add("modularPaths", modularPaths)
 
-      // Customized the JavaCompile for this source set so that it has proper module path.
+      // LUCENE-10344: We have to provide a special-case extension for ECJ because it does not
+      // support all of the module-specific javac options.
+      ModularPathsExtension modularPathsForEcj = modularPaths
+      if (sourceSet.name == SourceSet.TEST_SOURCE_SET_NAME && project.path in [
+          ":lucene:spatial-extras",
+          ":lucene:spatial3d",
+      ]) {
+        modularPathsForEcj = modularPaths.cloneWithMode(ModularPathsExtension.Mode.CLASSPATH_ONLY)
+      }
+      sourceSet.extensions.add("modularPathsForEcj", modularPathsForEcj)
+
+      // TODO: the tests of these projects currently don't compile or work in
+      // module-path mode. Make the modular paths extension use class path only.
+      if (sourceSet.name == SourceSet.TEST_SOURCE_SET_NAME && project.path in [
+        // Circular dependency between artifacts or source set outputs,
+        // causing package split issues at runtime.
+        ":lucene:core",
+        ":lucene:codecs",
+        ":lucene:test-framework",
+      ]) {
+        modularPaths.mode = ModularPathsExtension.Mode.CLASSPATH_ONLY
+      }
+
+      // Configure the JavaCompile task associated with this source set.
       tasks.named(sourceSet.getCompileJavaTaskName()).configure({ JavaCompile task ->
         task.dependsOn modularPaths.compileModulePathConfiguration
 
-        // LUCENE-10327: don't allow gradle to emit an empty sourcepath as it would break compilation.
+        // LUCENE-10327: don't allow gradle to emit an empty sourcepath as it would break
+        // compilation of modules.
         task.options.setSourcepath(sourceSet.java.sourceDirectories)
 
         // Add modular dependencies and their transitive dependencies to module path.
-        task.options.compilerArgumentProviders.add((CommandLineArgumentProvider) {
-          def modularPathFiles = modularPaths.compileModulePathConfiguration.files
-          def extraArgs = []
-          if (!modularPathFiles.isEmpty()) {
-            if (!modularPaths.hasModuleDescriptor()) {
-              // We're compiling a non-module so we'll bring everything on module path in
-              // otherwise things wouldn't be part of the resolved module graph.
-              extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
-            }
-
-            extraArgs += ["--module-path", modularPathFiles.join(File.pathSeparator)]
-          }
-
-          task.logger.info("Module path for ${task.path}:\n  " + modularPathFiles.sort().join("\n  "))
-
-          return extraArgs
-        })
+        task.options.compilerArgumentProviders.add(modularPaths.compilationArguments)
 
         // LUCENE-10304: if we modify the classpath here, IntelliJ no longer sees the dependencies as compile-time
         // dependencies, don't know why.
         if (!rootProject.ext.isIdea) {
-          // Modify the default classpath by removing anything already placed on module path.
-          // This could be done in a fancier way but a set difference is just fine for us here. Use a lazy
-          // provider to delay computation of the actual path.
-          task.classpath = files({ ->
-            def trimmedClasspath = sourceSet.compileClasspath - modularPaths.compileModulePathConfiguration
-            task.logger.info("Class path for ${task.path}:\n  " + trimmedClasspath.files.sort().join("\n  "))
-            return trimmedClasspath
-          })
+          task.classpath = modularPaths.compilationClasspath
+        }
+
+        doFirst {
+          modularPaths.logCompilationPaths(logger)
         }
       })
+
+      // For source sets that contain a module descriptor, configure a jar task that combines
+      // classes and resources into a single module.
+      if (sourceSet.name != SourceSet.MAIN_SOURCE_SET_NAME) {
+        tasks.maybeCreate(sourceSet.getJarTaskName(), org.gradle.jvm.tasks.Jar).configure({
+          archiveClassifier = sourceSet.name
+          from(sourceSet.output)
+        })
+      }
+    }
+
+    // Connect modular configurations between their "test" and "main" source sets, this reflects
+    // the conventions set by the Java plugin.
+    project.configurations {
+      moduleTestApi.extendsFrom moduleApi
+      moduleTestImplementation.extendsFrom moduleImplementation
+      moduleTestRuntimeOnly.extendsFrom moduleRuntimeOnly
+      moduleTestCompileOnly.extendsFrom moduleCompileOnly
     }
 
+    // Gradle's java plugin sets the compile and runtime classpath to be a combination
+    // of configuration dependencies and source set's outputs. For source sets with modules,
+    // this leads to split class and resource folders.
     //
-    // Configure the (default) test task to use module paths.
+    // We tweak the default source set path configurations here by assembling jar task outputs
+    // of the respective source set, instead of their source set output folders. We also attach
+    // the main source set's jar to the modular test implementation configuration.
+    SourceSet mainSourceSet = project.sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME)
+    boolean mainIsModular = mainSourceSet.modularPaths.hasModuleDescriptor()
+    boolean mainIsEmpty = mainSourceSet.allJava.isEmpty()
+    SourceSet testSourceSet = project.sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME)
+    boolean testIsModular = testSourceSet.modularPaths.hasModuleDescriptor()
+
+    // LUCENE-10304: if we modify the classpath here, IntelliJ no longer sees the dependencies as compile-time
+    // dependencies, don't know why.
+    if (!rootProject.ext.isIdea) {
+      def jarTask = project.tasks.getByName(mainSourceSet.getJarTaskName())
+      def testJarTask = project.tasks.getByName(testSourceSet.getJarTaskName())
+
+      // Consider various combinations of module/classpath configuration between the main and test source set.
+      if (testIsModular) {
+        if (mainIsModular || mainIsEmpty) {
+          // If the main source set is empty, skip the jar task.
+          def jarTaskOutputs = mainIsEmpty ? [] :  jarTask.outputs
+
+          // Fully modular tests - must have no split packages, proper access, etc.
+          // Work around the split classes/resources problem by adjusting classpaths to
+          // rely on JARs rather than source set output folders.
+          testSourceSet.compileClasspath = project.objects.fileCollection().from(
+              jarTaskOutputs,
+              project.configurations.getByName(testSourceSet.getCompileClasspathConfigurationName()),
+          )
+          testSourceSet.runtimeClasspath = project.objects.fileCollection().from(
+              jarTaskOutputs,
+              testJarTask.outputs,
+              project.configurations.getByName(testSourceSet.getRuntimeClasspathConfigurationName()),
+          )
+
+          project.dependencies {
+            moduleTestImplementation files(jarTaskOutputs)
+            moduleTestRuntimeOnly files(testJarTask.outputs)
+          }
+        } else {
+          // This combination simply does not make any sense (in my opinion).
+          throw GradleException("Test source set is modular and main source set is class-based, this makes no sense: " + project.path)
+        }
+      } else {
+        if (mainIsModular) {
+          // This combination is a potential candidate for patching the main sourceset's module with test classes. I could
+          // not resolve all the difficulties that arise when you try to do it though:
+          // - either a separate module descriptor is needed that opens test packages, adds dependencies via requires clauses
+          // or a series of jvm arguments (--add-reads, --add-opens, etc.) has to be generated and maintained. This is
+          // very low-level (ECJ doesn't support a full set of these instructions, for example).
+          //
+          // Fall back to classpath mode.
+        } else {
+          // This is the 'plain old classpath' mode: neither the main source set nor the test set are modular.
+        }
+      }
+    }
+
+    //
+    // Configures a Test task associated with the provided source set to use module paths.
     //
     // There is no explicit connection between source sets and test tasks so there is no way (?)
     // to do this automatically, convention-style.
-
+    //
     // This closure can be used to configure a different task, with a different source set, should we
     // have the need for it.
     Closure<Void> configureTestTaskForSourceSet = { Test task, SourceSet sourceSet ->
       task.configure {
-        Configuration modulePath = task.project.configurations.maybeCreate(
-            moduleConfigurationNameFor(sourceSet.getRuntimeClasspathConfigurationName()))
+        ModularPathsExtension modularPaths = sourceSet.modularPaths
 
-        task.dependsOn modulePath
+        dependsOn modularPaths
 
         // Add modular dependencies and their transitive dependencies to module path.
-        task.jvmArgumentProviders.add((CommandLineArgumentProvider) {
-          def extraArgs = []
-
-          // Determine whether the source set classes themselves should be appended
-          // to classpath or module path.
-          boolean sourceSetIsAModule = sourceSet.modularPaths.hasModuleDescriptor()
-
-          if (!modulePath.isEmpty() || sourceSetIsAModule) {
-            if (sourceSetIsAModule) {
-              // Add source set outputs to module path.
-              extraArgs += ["--module-path", (modulePath + sourceSet.output.classesDirs).files.join(File.pathSeparator)]
-              // Ideally, we should only add the sourceset's module here, everything else would be resolved via the
-              // module descriptor. But this would require parsing the module descriptor and may cause JVM version conflicts
-              // so keeping it simple.
-              extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
-            } else {
-              extraArgs += ["--module-path", modulePath.files.join(File.pathSeparator)]
-              // In this case we're running a non-module against things on the module path so let's bring in
-              // everything on module path into the resolution graph.
-              extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
-            }
-          }
+        jvmArgumentProviders.add(modularPaths.runtimeArguments)
 
-          task.logger.info("Module path for ${task.path}:\n  " + modulePath.files.sort().join("\n  "))
-
-          return extraArgs
-        })
+        // Modify the default classpath.
+        classpath = modularPaths.runtimeClasspath
 
-
-        // Modify the default classpath by removing anything already placed on module path.
-        // This could be done in a fancier way but a set difference is just fine for us here. Use a lazy
-        // provider to delay computation of the actual path.
-        task.classpath = files({ ->
-          def trimmedClasspath = sourceSet.runtimeClasspath - modulePath
-
-          boolean sourceSetIsAModule = sourceSet.modularPaths.hasModuleDescriptor()
-          if (sourceSetIsAModule) {
-            // also subtract the sourceSet's output directories.
-            trimmedClasspath = trimmedClasspath - sourceSet.output.classesDirs
-          }
-
-          task.logger.info("Class path for ${task.path}:\n  " + trimmedClasspath.files.sort().join("\n  "))
-          return trimmedClasspath
-        })
+        doFirst {
+          modularPaths.logRuntimePaths(logger)
+        }
       }
     }
 
@@ -237,19 +212,233 @@ allprojects {
 }
 
 
-class ModularPathsExtension {
+//
+// For a source set, create explicit configurations for declaring modular dependencies.
+//
+// These "modular" configurations correspond 1:1 to Gradle's conventions but have a 'module' prefix
+// and a capitalized remaining part of the conventional name. For example, an 'api' configuration in
+// the main source set would have a corresponding 'moduleApi' configuration for declaring modular
+// dependencies.
+//
+// Gradle's java plugin "convention" configurations extend from their modular counterparts
+// so all dependencies end up on classpath by default for backward compatibility with other
+// tasks and gradle infrastructure.
+//
+// At the same time, we also know which dependencies (and their transitive graph of dependencies!)
+// should be placed on module-path only.
+//
+// Note that an explicit configuration of modular dependencies also opens up the possibility of automatically
+// validating whether the dependency configuration for a gradle project is consistent with the information in
+// the module-info descriptor because there is a (nearly?) direct correspondence between the two:
+//
+// moduleApi            - 'requires transitive'
+// moduleImplementation - 'requires'
+// moduleCompileOnly    - 'requires static'
+//
+class ModularPathsExtension implements Cloneable, Iterable<Object> {
+  /**
+   * Determines how paths are split between module path and classpath.
+   */
+  enum Mode {
+    /**
+     * Dependencies and source set outputs are placed on classpath, even if declared on modular
+     * configurations. This would be the 'default' backward-compatible mode.
+     */
+    CLASSPATH_ONLY,
+
+    /**
+     * Dependencies from modular configurations are placed on module path. Source set outputs
+     * are placed on classpath.
+     */
+    DEPENDENCIES_ON_MODULE_PATH
+  }
+
   Project project
   SourceSet sourceSet
   Configuration compileModulePathConfiguration
   Configuration runtimeModulePathConfiguration
+  Configuration modulePatchOnlyConfiguration
 
-  ModularPathsExtension(Project project, SourceSet sourceSet,
-                        Configuration compileModulePathConfiguration,
-                        Configuration runtimeModulePathConfiguration) {
-    this.project =  project
+  // The mode of splitting paths for this source set.
+  Mode mode = Mode.DEPENDENCIES_ON_MODULE_PATH
+
+  // More verbose debugging for paths.
+  private boolean debugPaths
+
+  /**
+   * A list of module name - path provider entries that will be converted
+   * into {@code --patch-module} options.
+   */
+  private List<Map.Entry<String, Provider<Path>>> modulePatches = new ArrayList<>()
+
+  ModularPathsExtension(Project project, SourceSet sourceSet) {
+    this.project = project
     this.sourceSet = sourceSet
-    this.compileModulePathConfiguration = compileModulePathConfiguration
-    this.runtimeModulePathConfiguration = runtimeModulePathConfiguration
+
+    debugPaths = Boolean.parseBoolean(project.propertyOrDefault('build.debug.paths', 'false'))
+
+    ConfigurationContainer configurations = project.configurations
+
+    // Create modular configurations for gradle's java plugin convention configurations.
+    Configuration moduleApi = createModuleConfigurationForConvention(sourceSet.apiConfigurationName)
+    Configuration moduleImplementation = createModuleConfigurationForConvention(sourceSet.implementationConfigurationName)
+    Configuration moduleRuntimeOnly = createModuleConfigurationForConvention(sourceSet.runtimeOnlyConfigurationName)
+    Configuration moduleCompileOnly = createModuleConfigurationForConvention(sourceSet.compileOnlyConfigurationName)
+
+
+    // Apply hierarchy relationships to modular configurations.
+    moduleImplementation.extendsFrom(moduleApi)
+
+    // Patched modules have to end up in the implementation configuration for IDEs, which
+    // otherwise get terribly confused.
+    Configuration modulePatchOnly = createModuleConfigurationForConvention(
+        SourceSet.isMain(sourceSet) ? "patchOnly" : sourceSet.name + "PatchOnly")
+    modulePatchOnly.canBeResolved(true)
+    moduleImplementation.extendsFrom(modulePatchOnly)
+    this.modulePatchOnlyConfiguration = modulePatchOnly
+
+    // This part of convention configurations seems like a very esoteric use case, leave out for now.
+    // sourceSet.compileOnlyApiConfigurationName
+
+    // We have to ensure configurations are using assembled resources and classes (jar variant) as a single
+    // module can't be expanded into multiple folders.
+    Closure<Void> ensureJarVariant = { Configuration c ->
+      c.attributes.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, project.objects.named(LibraryElements, LibraryElements.JAR))
+    }
+
+    // Set up compilation module path configuration combining corresponding convention configurations.
+    Closure<Configuration> createResolvableModuleConfiguration = { String configurationName ->
+      Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
+      Configuration moduleConfiguration = configurations.maybeCreate(moduleConfigurationNameFor(conventionConfiguration.name))
+      moduleConfiguration.canBeConsumed(false)
+      moduleConfiguration.canBeResolved(true)
+      ensureJarVariant(moduleConfiguration)
+
+      project.logger.info("Created resolvable module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
+      return moduleConfiguration
+    }
+
+    ensureJarVariant(configurations.maybeCreate(sourceSet.compileClasspathConfigurationName))
+    ensureJarVariant(configurations.maybeCreate(sourceSet.runtimeClasspathConfigurationName))
+
+    this.compileModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.compileClasspathConfigurationName)
+    compileModulePathConfiguration.extendsFrom(moduleCompileOnly, moduleImplementation)
+
+    this.runtimeModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.runtimeClasspathConfigurationName)
+    runtimeModulePathConfiguration.extendsFrom(moduleRuntimeOnly, moduleImplementation)
+  }
+
+  /**
+   * Adds {@code --patch-module} option for the provided module name and the provider of a
+   * folder or JAR file.
+   *
+   * @param moduleName
+   * @param pathProvider
+   */
+  void patchModule(String moduleName, Provider<Path> pathProvider) {
+    modulePatches.add(Map.entry(moduleName, pathProvider));
+  }
+
+  private FileCollection getCompilationModulePath() {
+    if (mode == Mode.CLASSPATH_ONLY) {
+      return project.files()
+    }
+    return compileModulePathConfiguration - modulePatchOnlyConfiguration
+  }
+
+  private FileCollection getRuntimeModulePath() {
+    if (mode == Mode.CLASSPATH_ONLY) {
+      if (hasModuleDescriptor()) {
+        // The source set is itself a module.
+        throw new GradleException("Source set contains a module but classpath-only" +
+            " dependencies requested: ${project.path}, source set '${sourceSet.name}'")
+      }
+
+      return project.files()
+    }
+
+    return runtimeModulePathConfiguration - modulePatchOnlyConfiguration
+  }
+
+  FileCollection getCompilationClasspath() {
+    if (mode == Mode.CLASSPATH_ONLY) {
+      return sourceSet.compileClasspath
+    }
+
+    // Modify the default classpath by removing anything already placed on module path.
+    // Use a lazy provider to delay computation.
+    project.files({ ->
+      return sourceSet.compileClasspath - compileModulePathConfiguration - modulePatchOnlyConfiguration
+    })
+  }
+
+  CommandLineArgumentProvider getCompilationArguments() {
+    return new CommandLineArgumentProvider() {
+      @Override
+      Iterable<String> asArguments() {
+        FileCollection modulePath = ModularPathsExtension.this.compilationModulePath
+
+        if (modulePath.isEmpty()) {
+          return []
+        }
+
+        ArrayList<String> extraArgs = []
+        extraArgs += ["--module-path", modulePath.join(File.pathSeparator)]
+
+        if (!hasModuleDescriptor()) {
+          // We're compiling what appears to be a non-module source set so we'll
+          // bring everything on module path in the resolution graph,
+          // otherwise modular dependencies wouldn't be part of the resolved module graph and this
+          // would result in class-not-found compilation problems.
+          extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
+        }
+
+        // Add module-patching.
+        extraArgs += getPatchModuleArguments(modulePatches)
+
+        return extraArgs
+      }
+    }
+  }
+
+  FileCollection getRuntimeClasspath() {
+    if (mode == Mode.CLASSPATH_ONLY) {
+      return sourceSet.runtimeClasspath
+    }
+
+    // Modify the default classpath by removing anything already placed on module path.
+    // Use a lazy provider to delay computation.
+    project.files({ ->
+      return sourceSet.runtimeClasspath - runtimeModulePath - modulePatchOnlyConfiguration
+    })
+  }
+
+  CommandLineArgumentProvider getRuntimeArguments() {
+    return new CommandLineArgumentProvider() {
+      @Override
+      Iterable<String> asArguments() {
+        FileCollection modulePath = ModularPathsExtension.this.runtimeModulePath
+
+        if (modulePath.isEmpty()) {
+          return []
+        }
+
+        def extraArgs = []
+
+        // Add source set outputs to module path.
+        extraArgs += ["--module-path", modulePath.files.join(File.pathSeparator)]
+
+        // Ideally, we should only add the sourceset's module here, everything else would be resolved via the
+        // module descriptor. But this would require parsing the module descriptor and may cause JVM version conflicts
+        // so keeping it simple.
+        extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
+
+        // Add module-patching.
+        extraArgs += getPatchModuleArguments(modulePatches)
+
+        return extraArgs
+      }
+    }
   }
 
   boolean hasModuleDescriptor() {
@@ -257,4 +446,86 @@ class ModularPathsExtension {
         .map(dir -> new File(dir, "module-info.java"))
         .anyMatch(file -> file.exists())
   }
+
+  private List<String> getPatchModuleArguments(List<Map.Entry<String, Provider<Path>>> patches) {
+    def args = []
+    patches.each {
+      args.add("--patch-module");
+      args.add(it.key + "=" + it.value.get())
+    }
+    return args
+  }
+
+  private static String toList(FileCollection files) {
+    return files.isEmpty() ? " [empty]" : ("\n    " + files.sort().join("\n    "))
+  }
+
+  private static String toList(List<Map.Entry<String, Provider<Path>>> patches) {
+    return patches.isEmpty() ? " [empty]" : ("\n    " + patches.collect {"${it.key}=${it.value.get()}"}.join("\n    "))
+  }
+
+  public void logCompilationPaths(Logger logger) {
+    def value = "Modular extension, compilation paths, source set=${sourceSet.name}${hasModuleDescriptor() ? " (module)" : ""}, mode=${mode}:\n" +
+        "  Module path:${toList(compilationModulePath)}\n" +
+        "  Class path: ${toList(compilationClasspath)}\n" +
+        "  Patches:    ${toList(modulePatches)}"
+
+    if (debugPaths) {
+      logger.lifecycle(value)
+    } else {
+      logger.info(value)
+    }
+  }
+
+  public void logRuntimePaths(Logger logger) {
+    def value = "Modular extension, runtime paths, source set=${sourceSet.name}${hasModuleDescriptor() ? " (module)" : ""}, mode=${mode}:\n" +
+        "  Module path:${toList(runtimeModulePath)}\n" +
+        "  Class path: ${toList(runtimeClasspath)}\n" +
+        "  Patches   : ${toList(modulePatches)}"
+
+    if (debugPaths) {
+      logger.lifecycle(value)
+    } else {
+      logger.info(value)
+    }
+  }
+
+  public ModularPathsExtension clone() {
+    return (ModularPathsExtension) super.clone()
+  }
+
+  ModularPathsExtension cloneWithMode(Mode newMode) {
+    def cloned = this.clone()
+    cloned.mode = newMode
+    return cloned
+  }
+
+  // Map convention configuration names to "modular" corresponding configurations.
+  static String moduleConfigurationNameFor(String configurationName) {
+    return "module" + configurationName.capitalize().replace("Classpath", "Path")
+  }
+
+  // Create module configuration for the corresponding convention configuration.
+  private Configuration createModuleConfigurationForConvention(String configurationName) {
+    ConfigurationContainer configurations = project.configurations
+    Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
+    Configuration moduleConfiguration = configurations.maybeCreate(moduleConfigurationNameFor(configurationName))
+    moduleConfiguration.canBeConsumed(false)
+    moduleConfiguration.canBeResolved(false)
+    conventionConfiguration.extendsFrom(moduleConfiguration)
+
+    project.logger.info("Created module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
+    return moduleConfiguration
+  }
+
+  /**
+   * Provide internal dependencies for tasks willing to depend on this modular paths object.
+   */
+  @Override
+  Iterator<Object> iterator() {
+    return [
+        compileModulePathConfiguration,
+        runtimeModulePathConfiguration
+    ].iterator()
+  }
 }
\ No newline at end of file
diff --git a/gradle/maven/publications.gradle b/gradle/maven/publications.gradle
index cc9bc96..17b444b 100644
--- a/gradle/maven/publications.gradle
+++ b/gradle/maven/publications.gradle
@@ -38,7 +38,9 @@ configure(rootProject) {
           // Exclude the parent container project for analysis modules (no artifacts).
           ":lucene:analysis",
           // Exclude the native module.
-          ":lucene:misc:native"
+          ":lucene:misc:native",
+          // Exclude test fixtures.
+          ":lucene:spatial-test-fixtures"
       ]
 
       // Exclude all subprojects that are modular test projects and those explicitly
diff --git a/gradle/validation/check-environment.gradle b/gradle/validation/check-environment.gradle
index 7694f8d..00912c4 100644
--- a/gradle/validation/check-environment.gradle
+++ b/gradle/validation/check-environment.gradle
@@ -33,14 +33,20 @@ configure(rootProject) {
   def currentJavaVersion = JavaVersion.current()
   if (currentJavaVersion < minJavaVersion) {
     throw new GradleException("At least Java ${minJavaVersion} is required, you are running Java ${currentJavaVersion} "
-      + "[${System.getProperty('java.vm.name')} ${System.getProperty('java.vm.version')}]")
+        + "[${System.getProperty('java.vm.name')} ${System.getProperty('java.vm.version')}]")
   }
 
   // If we're regenerating the wrapper, skip the check.
   if (!gradle.startParameter.taskNames.contains("wrapper")) {
     def currentGradleVersion = GradleVersion.current()
     if (currentGradleVersion != GradleVersion.version(expectedGradleVersion)) {
-      throw new GradleException("Gradle ${expectedGradleVersion} is required (hint: use the gradlew script): this gradle is ${currentGradleVersion}")
+      if (currentGradleVersion.baseVersion == GradleVersion.version(expectedGradleVersion).baseVersion) {
+        logger.warn("Gradle ${expectedGradleVersion} is required but base version of this gradle matches, proceeding (" +
+            "this gradle is ${currentGradleVersion})")
+      } else {
+        throw new GradleException("Gradle ${expectedGradleVersion} is required (hint: use the gradlew script): " +
+            "this gradle is ${currentGradleVersion}")
+      }
     }
   }
 }
diff --git a/gradle/validation/ecj-lint.gradle b/gradle/validation/ecj-lint.gradle
index 0d99d2c..33b79ca 100644
--- a/gradle/validation/ecj-lint.gradle
+++ b/gradle/validation/ecj-lint.gradle
@@ -74,42 +74,36 @@ allprojects {
         args += [ "-properties", file("${resources}/ecj.javadocs.prefs").absolutePath ]
 
         // We depend on modular paths.
-        def modularPaths = sourceSet.modularPaths
-        dependsOn modularPaths.compileModulePathConfiguration
+        def modularPaths = sourceSet.modularPathsForEcj
+        dependsOn modularPaths
 
-        // Place input files in an external file to dodge command line argument
-        // limits. We could pass a directory but ecj seems to be buggy: when it
-        // encounters a module-info.java file it no longer compiles other source files.
-        def inputsFile = file("${tmpDst}/ecj-inputs.txt")
+        // Add modular dependencies and their transitive dependencies to module path.
+        task.argumentProviders.add(modularPaths.compilationArguments)
 
+        // Add classpath, if needed.
         task.argumentProviders.add((CommandLineArgumentProvider) {
-          // Add modular dependencies and their transitive dependencies to module path.
-          def modularPathFiles = modularPaths.compileModulePathConfiguration.files
-          def extraArgs = []
-          if (!modularPathFiles.isEmpty()) {
-            if (!modularPaths.hasModuleDescriptor()) {
-              // We're compiling a non-module so we'll bring everything on module path in
-              // otherwise things wouldn't be part of the resolved module graph.
-              extraArgs += ["--add-modules", "ALL-MODULE-PATH"]
-            }
-
-            extraArgs += ["--module-path", modularPathFiles.join(File.pathSeparator)]
-          }
-
           // Add classpath locations in a lazy provider (can't resolve the
           // configuration at evaluation time). Filter out non-existing entries
           // (output folders for non-existing input source dirs like resources).
-          def cpath = sourceSet.compileClasspath.filter { p -> p.exists() }
-          cpath = cpath - modularPathFiles
+          FileCollection cpath = modularPaths.compilationClasspath.filter { p -> p.exists() }
           if (!cpath.isEmpty()) {
-            extraArgs += ["-classpath", cpath.join(File.pathSeparator)]
+            return ["-classpath", cpath.join(File.pathSeparator)]
+          } else {
+            return []
           }
+        })
 
-          extraArgs += ["@" + inputsFile.absolutePath]
-          return extraArgs
+        // Place input files in an external file to dodge command line argument
+        // limits. We could pass a directory but ecj seems to be buggy: when it
+        // encounters a module-info.java file it no longer compiles other source files.
+        def inputsFile = file("${tmpDst}/ecj-inputs.txt")
+        task.argumentProviders.add((CommandLineArgumentProvider) {
+          return ["@" + inputsFile.absolutePath]
         })
 
         doFirst {
+          modularPaths.logCompilationPaths(logger)
+
           tmpDst.mkdirs()
 
           // escape filename accoring to ECJ's rules:
@@ -118,7 +112,12 @@ allprojects {
           def escapeFileName = { String s -> s.replaceAll(/ +/, /"$0"/) }
           inputsFile.setText(
             srcDirs.collectMany { dir ->
-              project.fileTree(dir: dir, include: "**/*.java" ).files
+              project.fileTree(
+                  dir: dir,
+                  include: "**/*.java",
+                  // Exclude the benchmark class with dependencies on nekohtml, which causes module-classpath conflicts and breaks ecj.
+                  exclude: "**/DemoHTMLParser.java"
+              ).files
             }
             // Try to sort all input files; a side-effect of this should be that module-info.java
             // is placed first on the list, which works around ECJ bug:
diff --git a/gradle/validation/validate-source-patterns.gradle b/gradle/validation/validate-source-patterns.gradle
index cc01d37..40080eae 100644
--- a/gradle/validation/validate-source-patterns.gradle
+++ b/gradle/validation/validate-source-patterns.gradle
@@ -154,12 +154,11 @@ class ValidateSourcePatternsTask extends DefaultTask {
       (~$/\n\s*var\s+.*=.*<>.*/$) : 'Diamond operators should not be used with var'
     ]
 
-    def found = 0;
     def violations = new TreeSet();
     def reportViolation = { f, name ->
-      logger.error('{}: {}', name, f);
-      violations.add(name);
-      found++;
+      String msg = String.format(Locale.ROOT, "%s: %s", f, name)
+      logger.error(msg)
+      violations.add(msg)
     }
 
     def javadocsPattern = ~$/(?sm)^\Q/**\E(.*?)\Q*/\E/$;
@@ -258,9 +257,10 @@ class ValidateSourcePatternsTask extends DefaultTask {
     }
     progress.completed()
 
-    if (found) {
-      throw new GradleException(String.format(Locale.ENGLISH, 'Found %d violations in source files (%s).',
-        found, violations.join(', ')));
+    if (!violations.isEmpty()) {
+      throw new GradleException(String.format(Locale.ENGLISH, 'Found %d source violation(s):\n  %s',
+        violations.size(),
+        violations.join('\n  ')))
     }
   }
 }
diff --git a/lucene/analysis/common/build.gradle b/lucene/analysis/common/build.gradle
index 1815041..6530ac6 100644
--- a/lucene/analysis/common/build.gradle
+++ b/lucene/analysis/common/build.gradle
@@ -21,7 +21,7 @@ description = 'Analyzers for indexing content in different languages and domains
 
 dependencies {
   moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
 
 // Fetch the data and enable regression tests against woorm/ libreoffice dictionaries.
diff --git a/lucene/analysis/icu/build.gradle b/lucene/analysis/icu/build.gradle
index e76b327..aba455f 100644
--- a/lucene/analysis/icu/build.gradle
+++ b/lucene/analysis/icu/build.gradle
@@ -25,5 +25,5 @@ dependencies {
 
   moduleApi 'com.ibm.icu:icu4j'
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/analysis/kuromoji/build.gradle b/lucene/analysis/kuromoji/build.gradle
index 07fc0b0..fd8aedb 100644
--- a/lucene/analysis/kuromoji/build.gradle
+++ b/lucene/analysis/kuromoji/build.gradle
@@ -23,5 +23,5 @@ dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:analysis:common')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/analysis/morfologik.tests/build.gradle b/lucene/analysis/morfologik.tests/build.gradle
index 9cd6720..d2f94dd 100644
--- a/lucene/analysis/morfologik.tests/build.gradle
+++ b/lucene/analysis/morfologik.tests/build.gradle
@@ -21,8 +21,5 @@ description = 'Module tests for :lucene:analysis:morfologik'
 
 dependencies {
   moduleTestImplementation project(':lucene:analysis:morfologik')
-  moduleTestImplementation("junit:junit", {
-    exclude group: "org.hamcrest"
-  })
-  moduleTestImplementation "org.hamcrest:hamcrest"
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/analysis/morfologik.tests/src/test/module-info.java b/lucene/analysis/morfologik.tests/src/test/module-info.java
index 15642a9..b6f869f 100644
--- a/lucene/analysis/morfologik.tests/src/test/module-info.java
+++ b/lucene/analysis/morfologik.tests/src/test/module-info.java
@@ -21,7 +21,7 @@ module org.apache.lucene.analysis.morfologik.tests {
   requires org.apache.lucene.core;
   requires org.apache.lucene.analysis.common;
   requires org.apache.lucene.analysis.morfologik;
-  requires junit;
+  requires org.apache.lucene.test_framework;
 
   exports org.apache.lucene.analysis.morfologik.tests;
 }
diff --git a/lucene/analysis/morfologik.tests/src/test/org/apache/lucene/analysis/morfologik/tests/TestMorfologikAnalyzer.java b/lucene/analysis/morfologik.tests/src/test/org/apache/lucene/analysis/morfologik/tests/TestMorfologikAnalyzer.java
index c2933fc..b1607d9 100644
--- a/lucene/analysis/morfologik.tests/src/test/org/apache/lucene/analysis/morfologik/tests/TestMorfologikAnalyzer.java
+++ b/lucene/analysis/morfologik.tests/src/test/org/apache/lucene/analysis/morfologik/tests/TestMorfologikAnalyzer.java
@@ -19,28 +19,24 @@ package org.apache.lucene.analysis.morfologik.tests;
 import org.apache.lucene.analysis.morfologik.MorfologikAnalyzer;
 import org.apache.lucene.analysis.uk.UkrainianMorfologikAnalyzer;
 import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.tests.util.LuceneTestCase;
 import org.junit.Assert;
-import org.junit.Test;
 
-public class TestMorfologikAnalyzer {
-  @Test
+public class TestMorfologikAnalyzer extends LuceneTestCase {
   public void testMorfologikAnalyzerLoads() {
     var analyzer = new MorfologikAnalyzer();
     Assert.assertNotNull(analyzer);
   }
 
-  @Test
   public void testUkrainianMorfologikAnalyzerLoads() {
     var analyzer = new UkrainianMorfologikAnalyzer();
     Assert.assertNotNull(analyzer);
   }
 
-  @Test
   public void testWeAreModule() {
     Assert.assertTrue(this.getClass().getModule().isNamed());
   }
 
-  @Test
   public void testLuceneIsAModule() {
     Assert.assertTrue(IndexWriter.class.getModule().isNamed());
   }
diff --git a/lucene/analysis/morfologik/build.gradle b/lucene/analysis/morfologik/build.gradle
index 03d7c09..4faee5d 100644
--- a/lucene/analysis/morfologik/build.gradle
+++ b/lucene/analysis/morfologik/build.gradle
@@ -27,5 +27,5 @@ dependencies {
   moduleImplementation 'org.carrot2:morfologik-polish'
   moduleImplementation 'ua.net.nlp:morfologik-ukrainian-search'
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/analysis/nori/build.gradle b/lucene/analysis/nori/build.gradle
index 079cf50..c84ade8 100644
--- a/lucene/analysis/nori/build.gradle
+++ b/lucene/analysis/nori/build.gradle
@@ -22,7 +22,7 @@ description = 'Korean Morphological Analyzer'
 dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:analysis:common')
-  
-  testImplementation project(':lucene:test-framework')
+
+  moduleTestImplementation project(':lucene:test-framework')
 }
 
diff --git a/lucene/analysis/opennlp/build.gradle b/lucene/analysis/opennlp/build.gradle
index 3fee61a..2964e88 100644
--- a/lucene/analysis/opennlp/build.gradle
+++ b/lucene/analysis/opennlp/build.gradle
@@ -24,5 +24,5 @@ dependencies {
   moduleApi project(':lucene:analysis:common')
   moduleApi 'org.apache.opennlp:opennlp-tools'
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/analysis/phonetic/build.gradle b/lucene/analysis/phonetic/build.gradle
index 2297af5..1b8eee8 100644
--- a/lucene/analysis/phonetic/build.gradle
+++ b/lucene/analysis/phonetic/build.gradle
@@ -25,6 +25,6 @@ dependencies {
 
   moduleApi 'commons-codec:commons-codec'
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 } 
 
diff --git a/lucene/analysis/smartcn/build.gradle b/lucene/analysis/smartcn/build.gradle
index 960be70..87aafc9 100644
--- a/lucene/analysis/smartcn/build.gradle
+++ b/lucene/analysis/smartcn/build.gradle
@@ -23,5 +23,5 @@ dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:analysis:common')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 } 
diff --git a/lucene/analysis/stempel/build.gradle b/lucene/analysis/stempel/build.gradle
index 3299159..52d1b81 100644
--- a/lucene/analysis/stempel/build.gradle
+++ b/lucene/analysis/stempel/build.gradle
@@ -23,5 +23,5 @@ dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:analysis:common')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/backward-codecs/build.gradle b/lucene/backward-codecs/build.gradle
index d5e25d9..528c1a5 100644
--- a/lucene/backward-codecs/build.gradle
+++ b/lucene/backward-codecs/build.gradle
@@ -22,5 +22,5 @@ description = 'Codecs for older versions of Lucene'
 
 dependencies {
   moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/benchmark/build.gradle b/lucene/benchmark/build.gradle
index 9da51c3..97f17bd 100644
--- a/lucene/benchmark/build.gradle
+++ b/lucene/benchmark/build.gradle
@@ -36,11 +36,18 @@ dependencies {
   moduleImplementation "org.locationtech.spatial4j:spatial4j"
   moduleImplementation ("net.sourceforge.nekohtml:nekohtml", {
     exclude module: "xml-apis"
+    // LUCENE-10337: Exclude xercesImpl from module path because it has split packages with the JDK (!)
+    exclude module: "xercesImpl"
+  })
+
+  // LUCENE-10337: Include xercesImpl on regular classpath where it won't cause conflicts.
+  implementation ("xerces:xercesImpl", {
+    exclude module: "xml-apis"
   })
 
   moduleRuntimeOnly project(':lucene:analysis:icu')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
 
 // We add 'conf' to resources because we validate *.alg script correctness in one of the tests.
diff --git a/lucene/classification/build.gradle b/lucene/classification/build.gradle
index 8566cdb..1b8a1bd 100644
--- a/lucene/classification/build.gradle
+++ b/lucene/classification/build.gradle
@@ -25,7 +25,7 @@ dependencies {
   moduleImplementation project(':lucene:queries')
   moduleImplementation project(':lucene:grouping')
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation project(':lucene:analysis:common')
-  testImplementation project(':lucene:codecs')
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:analysis:common')
+  moduleTestImplementation project(':lucene:codecs')
 }
diff --git a/lucene/codecs/build.gradle b/lucene/codecs/build.gradle
index 92e0782..1de66fe 100644
--- a/lucene/codecs/build.gradle
+++ b/lucene/codecs/build.gradle
@@ -21,5 +21,5 @@ description = 'Lucene codecs and postings formats'
 
 dependencies {
     moduleImplementation project(':lucene:core')
-    testImplementation project(':lucene:test-framework')
+    moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/MockTermStateFactory.java b/lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/tests/MockTermStateFactory.java
similarity index 71%
rename from lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/MockTermStateFactory.java
rename to lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/tests/MockTermStateFactory.java
index 17ee5ef..69c36e7 100644
--- a/lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/MockTermStateFactory.java
+++ b/lucene/codecs/src/test/org/apache/lucene/codecs/lucene90/tests/MockTermStateFactory.java
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-package org.apache.lucene.codecs.lucene90;
+package org.apache.lucene.codecs.lucene90.tests;
 
-/** Test utility class to create mock {@link Lucene90PostingsFormat.IntBlockTermState}. */
-public class MockTermStateFactory {
+import org.apache.lucene.codecs.lucene90.Lucene90PostingsFormat.IntBlockTermState;
 
-  /** Creates an empty {@link Lucene90PostingsFormat.IntBlockTermState}. */
-  public static Lucene90PostingsFormat.IntBlockTermState create() {
-    return new Lucene90PostingsFormat.IntBlockTermState();
+/** Test utility class to create mock {@link IntBlockTermState}. */
+public class MockTermStateFactory {
+  /** Creates an empty {@link IntBlockTermState}. */
+  public static IntBlockTermState create() {
+    return new IntBlockTermState();
   }
 }
diff --git a/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestBlockWriter.java b/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestBlockWriter.java
index 5d74e7b..5f05cb0 100644
--- a/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestBlockWriter.java
+++ b/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/TestBlockWriter.java
@@ -19,7 +19,7 @@ package org.apache.lucene.codecs.uniformsplit;
 
 import java.io.IOException;
 import java.util.Collections;
-import org.apache.lucene.codecs.lucene90.MockTermStateFactory;
+import org.apache.lucene.codecs.lucene90.tests.MockTermStateFactory;
 import org.apache.lucene.index.DocValuesType;
 import org.apache.lucene.index.FieldInfo;
 import org.apache.lucene.index.IndexOptions;
diff --git a/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/sharedterms/TestSTBlockReader.java b/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/sharedterms/TestSTBlockReader.java
index 2a5e912..dc5a4c2 100644
--- a/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/sharedterms/TestSTBlockReader.java
+++ b/lucene/codecs/src/test/org/apache/lucene/codecs/uniformsplit/sharedterms/TestSTBlockReader.java
@@ -27,7 +27,7 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.lucene.codecs.BlockTermState;
 import org.apache.lucene.codecs.PostingsReaderBase;
-import org.apache.lucene.codecs.lucene90.MockTermStateFactory;
+import org.apache.lucene.codecs.lucene90.tests.MockTermStateFactory;
 import org.apache.lucene.codecs.uniformsplit.BlockHeader;
 import org.apache.lucene.codecs.uniformsplit.BlockLine;
 import org.apache.lucene.codecs.uniformsplit.FSTDictionary;
diff --git a/lucene/core.tests/build.gradle b/lucene/core.tests/build.gradle
index 9ada300..4bc8bf5 100644
--- a/lucene/core.tests/build.gradle
+++ b/lucene/core.tests/build.gradle
@@ -21,8 +21,5 @@ description = 'Module tests for :lucene:core'
 
 dependencies {
   moduleTestImplementation project(':lucene:core')
-  moduleTestImplementation("junit:junit", {
-    exclude group: "org.hamcrest"
-  })
-  moduleTestImplementation "org.hamcrest:hamcrest"
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/backward-codecs/build.gradle b/lucene/core.tests/src/java/module-info.java
similarity index 78%
copy from lucene/backward-codecs/build.gradle
copy to lucene/core.tests/src/java/module-info.java
index d5e25d9..28f8806 100644
--- a/lucene/backward-codecs/build.gradle
+++ b/lucene/core.tests/src/java/module-info.java
@@ -15,12 +15,10 @@
  * limitations under the License.
  */
 
-
-apply plugin: 'java-library'
-
-description = 'Codecs for older versions of Lucene'
-
-dependencies {
-  moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+/**
+ * A placeholder module descriptor just so that we can check whether referencing it from the actual
+ * test module works.
+ */
+module org.apache.lucene.core.tests.main {
+  exports org.apache.lucene.core.tests.main;
 }
diff --git a/lucene/backward-codecs/build.gradle b/lucene/core.tests/src/java/org/apache/lucene/core/tests/main/EmptyReference.java
similarity index 80%
copy from lucene/backward-codecs/build.gradle
copy to lucene/core.tests/src/java/org/apache/lucene/core/tests/main/EmptyReference.java
index d5e25d9..55c367b 100644
--- a/lucene/backward-codecs/build.gradle
+++ b/lucene/core.tests/src/java/org/apache/lucene/core/tests/main/EmptyReference.java
@@ -14,13 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.lucene.core.tests.main;
 
-
-apply plugin: 'java-library'
-
-description = 'Codecs for older versions of Lucene'
-
-dependencies {
-  moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+/** Just an empty class to reference from the actual tests. */
+public class EmptyReference {
+  /** No instantiation. */
+  private EmptyReference() {}
 }
diff --git a/lucene/core/build.gradle b/lucene/core.tests/src/java/org/apache/lucene/core/tests/main/package-info.java
similarity index 81%
copy from lucene/core/build.gradle
copy to lucene/core.tests/src/java/org/apache/lucene/core/tests/main/package-info.java
index 989c57f..5b9241b 100644
--- a/lucene/core/build.gradle
+++ b/lucene/core.tests/src/java/org/apache/lucene/core/tests/main/package-info.java
@@ -15,11 +15,5 @@
  * limitations under the License.
  */
 
-apply plugin: 'java-library'
-
-description = 'Lucene core library'
-
-dependencies {
-  testImplementation project(':lucene:codecs')
-  testImplementation project(':lucene:test-framework')
-}
+/** Contains a placeholder class for referencing in tests. */
+package org.apache.lucene.core.tests.main;
diff --git a/lucene/core.tests/src/java/overview.html b/lucene/core.tests/src/java/overview.html
new file mode 100644
index 0000000..e71200a
--- /dev/null
+++ b/lucene/core.tests/src/java/overview.html
@@ -0,0 +1,26 @@
+<!--
+ 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.
+-->
+<html>
+<head>
+  <title>Apache Lucene Core Tests</title>
+</head>
+  <body>
+
+    <p>A placeholder module descriptor just so that we can check whether
+      referencing it from the actual test module works.</p>
+  </body>
+</html>
diff --git a/lucene/core.tests/src/test/module-info.java b/lucene/core.tests/src/test/module-info.java
index 21fb271..26fa595 100644
--- a/lucene/core.tests/src/test/module-info.java
+++ b/lucene/core.tests/src/test/module-info.java
@@ -19,7 +19,8 @@
 @SuppressWarnings({"requires-automatic"})
 module org.apache.lucene.core.tests {
   requires org.apache.lucene.core;
-  requires junit;
+  requires org.apache.lucene.test_framework;
+  requires org.apache.lucene.core.tests.main;
 
   exports org.apache.lucene.core.tests;
 
diff --git a/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestMMap.java b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestMMap.java
index 2324846..fcaae01 100644
--- a/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestMMap.java
+++ b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestMMap.java
@@ -17,12 +17,11 @@
 package org.apache.lucene.core.tests;
 
 import org.apache.lucene.store.MMapDirectory;
+import org.apache.lucene.tests.util.LuceneTestCase;
 import org.junit.Assert;
-import org.junit.Test;
 
-public class TestMMap {
-  @Test
-  public void testUnmapSupported() throws Exception {
+public class TestMMap extends LuceneTestCase {
+  public void testUnmapSupported() {
     final Module module = MMapDirectory.class.getModule();
     Assert.assertTrue("Lucene Core is not loaded as module", module.isNamed());
     Assert.assertTrue(
diff --git a/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestModuleResourceLoader.java b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestModuleResourceLoader.java
index ac111e6..edbd705 100644
--- a/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestModuleResourceLoader.java
+++ b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestModuleResourceLoader.java
@@ -17,13 +17,12 @@
 package org.apache.lucene.core.tests;
 
 import java.io.IOException;
+import org.apache.lucene.tests.util.LuceneTestCase;
 import org.apache.lucene.util.ModuleResourceLoader;
 import org.apache.lucene.util.ResourceLoader;
-import org.junit.Assert;
 import org.junit.BeforeClass;
-import org.junit.Test;
 
-public class TestModuleResourceLoader extends Assert {
+public class TestModuleResourceLoader extends LuceneTestCase {
   private static final Module MODULE = TestModuleResourceLoader.class.getModule();
 
   private final ResourceLoader loader = new ModuleResourceLoader(MODULE);
@@ -33,7 +32,6 @@ public class TestModuleResourceLoader extends Assert {
     assertTrue("Test class must be in a named module", MODULE.isNamed());
   }
 
-  @Test
   public void testModuleResources() throws Exception {
     try (var stream = loader.openResource("org/apache/lucene/core/testresources/accessible.txt")) {
       stream.available();
@@ -51,8 +49,7 @@ public class TestModuleResourceLoader extends Assert {
     }
   }
 
-  @Test
-  public void testModuleClassloading() throws Exception {
+  public void testModuleClassloading() {
     assertSame(
         TestModuleResourceLoader.class,
         loader.findClass(TestModuleResourceLoader.class.getName(), Object.class));
diff --git a/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestRuntimeDependenciesSane.java b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestRuntimeDependenciesSane.java
new file mode 100644
index 0000000..2a07d4b
--- /dev/null
+++ b/lucene/core.tests/src/test/org/apache/lucene/core/tests/TestRuntimeDependenciesSane.java
@@ -0,0 +1,58 @@
+/*
+ * 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.core.tests;
+
+import org.apache.lucene.core.tests.main.EmptyReference;
+import org.apache.lucene.index.IndexWriter;
+import org.junit.Test;
+
+/** Intentionally not a subclass of {@code LuceneTestCase}. */
+public class TestRuntimeDependenciesSane {
+  @Test
+  public void testExternalDependenciesAreModules() {
+    isModule(org.junit.Test.class);
+  }
+
+  @Test
+  public void testInterProjectDependenciesAreModules() {
+    // The core Lucene should be a modular dependency.
+    isModule(IndexWriter.class);
+  }
+
+  @Test
+  public void testTestSourceSetIsAModule() {
+    // The test source set itself should be loaded as a module.
+    isModule(getClass());
+  }
+
+  @Test
+  public void testMainSourceSetIsAModule() {
+    // The test source set itself should be loaded as a module.
+    isModule(EmptyReference.class);
+  }
+
+  private static void isModule(Class<?> clazz) {
+    var module = clazz.getModule();
+    if (!module.isNamed()) {
+      throw new AssertionError(
+          "Class should be loaded from a named module: "
+              + clazz.getName()
+              + " but is instead part of: "
+              + module);
+    }
+  }
+}
diff --git a/lucene/core/build.gradle b/lucene/core/build.gradle
index 989c57f..e55c085 100644
--- a/lucene/core/build.gradle
+++ b/lucene/core/build.gradle
@@ -20,6 +20,6 @@ apply plugin: 'java-library'
 description = 'Lucene core library'
 
 dependencies {
-  testImplementation project(':lucene:codecs')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:codecs')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/demo/build.gradle b/lucene/demo/build.gradle
index 70fd1df..a5c9469 100644
--- a/lucene/demo/build.gradle
+++ b/lucene/demo/build.gradle
@@ -27,5 +27,5 @@ dependencies {
   moduleImplementation project(':lucene:queryparser')
   moduleImplementation project(':lucene:expressions')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/expressions/build.gradle b/lucene/expressions/build.gradle
index 6823ae6..f9fcee6 100644
--- a/lucene/expressions/build.gradle
+++ b/lucene/expressions/build.gradle
@@ -29,5 +29,5 @@ dependencies {
   moduleImplementation 'org.ow2.asm:asm'
   moduleImplementation 'org.ow2.asm:asm-commons'
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/facet/build.gradle b/lucene/facet/build.gradle
index 1b16f94..87b1950 100644
--- a/lucene/facet/build.gradle
+++ b/lucene/facet/build.gradle
@@ -22,11 +22,10 @@ description = 'Faceted indexing and search capabilities'
 
 dependencies {
   moduleApi project(':lucene:core')
-
   moduleImplementation 'com.carrotsearch:hppc'
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation project(':lucene:queries')
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:queries')
   // Required for opening older indexes for backward compatibility tests
-  testImplementation project(':lucene:backward-codecs')
+  moduleTestImplementation project(':lucene:backward-codecs')
 }
diff --git a/lucene/grouping/build.gradle b/lucene/grouping/build.gradle
index 7035a11..5915c78 100644
--- a/lucene/grouping/build.gradle
+++ b/lucene/grouping/build.gradle
@@ -25,5 +25,5 @@ dependencies {
 
   moduleImplementation project(':lucene:queries')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/highlighter/build.gradle b/lucene/highlighter/build.gradle
index 105f1de..d2e7baf 100644
--- a/lucene/highlighter/build.gradle
+++ b/lucene/highlighter/build.gradle
@@ -26,7 +26,7 @@ dependencies {
   moduleImplementation project(':lucene:queries')
   moduleImplementation project(':lucene:memory')
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation project(':lucene:analysis:common')
-  testImplementation project(':lucene:queryparser')
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:analysis:common')
+  moduleTestImplementation project(':lucene:queryparser')
 }
diff --git a/lucene/join/build.gradle b/lucene/join/build.gradle
index d8c2eab..840b3bc 100644
--- a/lucene/join/build.gradle
+++ b/lucene/join/build.gradle
@@ -21,5 +21,5 @@ description = 'Index-time and Query-time joins for normalized content'
 
 dependencies {
   moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
\ No newline at end of file
diff --git a/lucene/luke/build.gradle b/lucene/luke/build.gradle
index 6def3e6..1504240 100644
--- a/lucene/luke/build.gradle
+++ b/lucene/luke/build.gradle
@@ -45,7 +45,7 @@ dependencies {
   moduleImplementation project(':lucene:analysis:stempel')
   moduleImplementation project(':lucene:suggest')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
 
 // Configure main class name for all JARs.
diff --git a/lucene/memory/build.gradle b/lucene/memory/build.gradle
index eb6626e..789070f 100644
--- a/lucene/memory/build.gradle
+++ b/lucene/memory/build.gradle
@@ -23,6 +23,6 @@ description = 'Single-document in-memory index implementation'
 dependencies {
   moduleApi project(':lucene:core')
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation project(':lucene:queryparser')
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:queryparser')
 }
\ No newline at end of file
diff --git a/lucene/misc/build.gradle b/lucene/misc/build.gradle
index b9d7626..1c4468a 100644
--- a/lucene/misc/build.gradle
+++ b/lucene/misc/build.gradle
@@ -21,7 +21,7 @@ description = 'Index tools and other miscellaneous code'
 
 dependencies {
   moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 
   nativeDeps project(":lucene:misc:native")
 }
\ No newline at end of file
diff --git a/lucene/monitor/build.gradle b/lucene/monitor/build.gradle
index ff6677f..83d6759 100644
--- a/lucene/monitor/build.gradle
+++ b/lucene/monitor/build.gradle
@@ -25,6 +25,6 @@ dependencies {
   moduleImplementation project(':lucene:memory')
   moduleImplementation project(':lucene:analysis:common')
 
-  testImplementation project(':lucene:queryparser')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:queryparser')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/queries/build.gradle b/lucene/queries/build.gradle
index d0cc010..560365c 100644
--- a/lucene/queries/build.gradle
+++ b/lucene/queries/build.gradle
@@ -22,7 +22,7 @@ description = 'Filters and Queries that add to core Lucene'
 dependencies {
   moduleApi project(':lucene:core')
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation project(':lucene:expressions')
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:expressions')
 }
 
diff --git a/lucene/queryparser/build.gradle b/lucene/queryparser/build.gradle
index b50b565..2390cfe 100644
--- a/lucene/queryparser/build.gradle
+++ b/lucene/queryparser/build.gradle
@@ -24,5 +24,5 @@ dependencies {
   moduleApi project(':lucene:queries')
   moduleApi project(':lucene:sandbox')
 
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/replicator/build.gradle b/lucene/replicator/build.gradle
index d3dfc07..2058502 100644
--- a/lucene/replicator/build.gradle
+++ b/lucene/replicator/build.gradle
@@ -30,12 +30,12 @@ dependencies {
 
   moduleImplementation 'javax.servlet:javax.servlet-api'
 
-  testImplementation 'org.eclipse.jetty:jetty-server'
-  testImplementation('org.eclipse.jetty:jetty-servlet', {
+  moduleTestImplementation project(':lucene:test-framework')
+
+  moduleTestImplementation 'org.eclipse.jetty:jetty-server'
+  moduleTestImplementation('org.eclipse.jetty:jetty-servlet', {
     exclude group: "org.eclipse.jetty", module: "jetty-security"
   })
-  testImplementation 'org.eclipse.jetty:jetty-continuation'
-
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation 'org.eclipse.jetty:jetty-continuation'
 }
 
diff --git a/lucene/sandbox/build.gradle b/lucene/sandbox/build.gradle
index b7e8018..93f01e3 100644
--- a/lucene/sandbox/build.gradle
+++ b/lucene/sandbox/build.gradle
@@ -22,5 +22,5 @@ description = 'Various third party contributions and new ideas'
 dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:queries')
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/spatial-extras/build.gradle b/lucene/spatial-extras/build.gradle
index 34fa10e..baa772f 100644
--- a/lucene/spatial-extras/build.gradle
+++ b/lucene/spatial-extras/build.gradle
@@ -19,6 +19,10 @@ apply plugin: 'java-library'
 
 description = 'Geospatial search'
 
+configurations {
+  spatial4jTestPatch
+}
+
 dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:spatial3d')
@@ -26,11 +30,17 @@ dependencies {
   moduleApi 'org.locationtech.spatial4j:spatial4j'
   moduleApi 'io.sgr:s2-geometry-library-java'
 
-  testImplementation project(':lucene:test-framework')
-  testImplementation testFixtures(project(':lucene:spatial3d'))
-
+  moduleTestImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:spatial-test-fixtures')
   moduleTestImplementation 'org.locationtech.jts:jts-core'
-  testImplementation 'org.locationtech.spatial4j:spatial4j::tests'
-}
 
+  // We add patched modules to this configuration because otherwise IDEs would not see the
+  // dependency at all, even in classpath mode (they don't see --patch-module commands we
+  // add to the compiler and test tasks).
+  moduleTestPatchOnly 'org.locationtech.spatial4j:spatial4j::tests'
+  spatial4jTestPatch 'org.locationtech.spatial4j:spatial4j::tests'
+}
 
+sourceSets.test.extensions.configure("modularPaths", {
+  it.patchModule("spatial4j", project.providers.provider({ configurations.spatial4jTestPatch.singleFile }))
+})
\ No newline at end of file
diff --git a/lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java b/lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java
index 425142e..0d3e812 100644
--- a/lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java
+++ b/lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java
@@ -16,7 +16,7 @@
  */
 package org.apache.lucene.spatial.spatial4j;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 import static org.locationtech.spatial4j.distance.DistanceUtils.DEGREES_TO_RADIANS;
 
 import java.io.IOException;
diff --git a/lucene/backward-codecs/build.gradle b/lucene/spatial-test-fixtures/build.gradle
similarity index 83%
copy from lucene/backward-codecs/build.gradle
copy to lucene/spatial-test-fixtures/build.gradle
index d5e25d9..983416d 100644
--- a/lucene/backward-codecs/build.gradle
+++ b/lucene/spatial-test-fixtures/build.gradle
@@ -15,12 +15,11 @@
  * limitations under the License.
  */
 
-
 apply plugin: 'java-library'
 
-description = 'Codecs for older versions of Lucene'
+description = '3D spatial planar geometry test fixtures'
 
 dependencies {
-  moduleApi project(':lucene:core')
-  testImplementation project(':lucene:test-framework')
+  moduleImplementation project(':lucene:test-framework')
+  moduleImplementation project(':lucene:spatial3d')
 }
diff --git a/lucene/spatial3d/src/testFixtures/java/org/apache/lucene/spatial3d/geom/RandomGeo3dShapeGenerator.java b/lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/RandomGeo3dShapeGenerator.java
similarity index 95%
rename from lucene/spatial3d/src/testFixtures/java/org/apache/lucene/spatial3d/geom/RandomGeo3dShapeGenerator.java
rename to lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/RandomGeo3dShapeGenerator.java
index da4ac89..d3ddb3f 100644
--- a/lucene/spatial3d/src/testFixtures/java/org/apache/lucene/spatial3d/geom/RandomGeo3dShapeGenerator.java
+++ b/lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/RandomGeo3dShapeGenerator.java
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-package org.apache.lucene.spatial3d.geom;
+package org.apache.lucene.spatial3d.tests;
 
 import com.carrotsearch.randomizedtesting.RandomizedContext;
 import com.carrotsearch.randomizedtesting.RandomizedTest;
@@ -25,6 +25,23 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Random;
+import org.apache.lucene.spatial3d.geom.GeoArea;
+import org.apache.lucene.spatial3d.geom.GeoAreaShape;
+import org.apache.lucene.spatial3d.geom.GeoBBox;
+import org.apache.lucene.spatial3d.geom.GeoBBoxFactory;
+import org.apache.lucene.spatial3d.geom.GeoCircle;
+import org.apache.lucene.spatial3d.geom.GeoCircleFactory;
+import org.apache.lucene.spatial3d.geom.GeoCompositeAreaShape;
+import org.apache.lucene.spatial3d.geom.GeoCompositeMembershipShape;
+import org.apache.lucene.spatial3d.geom.GeoPath;
+import org.apache.lucene.spatial3d.geom.GeoPathFactory;
+import org.apache.lucene.spatial3d.geom.GeoPoint;
+import org.apache.lucene.spatial3d.geom.GeoPointShape;
+import org.apache.lucene.spatial3d.geom.GeoPointShapeFactory;
+import org.apache.lucene.spatial3d.geom.GeoPolygon;
+import org.apache.lucene.spatial3d.geom.GeoPolygonFactory;
+import org.apache.lucene.spatial3d.geom.GeoShape;
+import org.apache.lucene.spatial3d.geom.PlanetModel;
 
 /**
  * Class for generating random Geo3dShapes. They can be generated under given constraints which are
@@ -42,22 +59,22 @@ public final class RandomGeo3dShapeGenerator {
   private static final int MAX_POINT_ITERATIONS = 1000;
 
   /* Supported shapes */
-  protected static final int CONVEX_POLYGON = 0;
-  protected static final int CONVEX_POLYGON_WITH_HOLES = 1;
-  protected static final int CONCAVE_POLYGON = 2;
-  protected static final int CONCAVE_POLYGON_WITH_HOLES = 3;
-  protected static final int COMPLEX_POLYGON = 4;
-  protected static final int CIRCLE = 5;
-  protected static final int RECTANGLE = 6;
-  protected static final int PATH = 7;
-  protected static final int COLLECTION = 8;
-  protected static final int POINT = 9;
-  protected static final int LINE = 10;
-  protected static final int EXACT_CIRCLE = 11;
+  public static final int CONVEX_POLYGON = 0;
+  public static final int CONVEX_POLYGON_WITH_HOLES = 1;
+  public static final int CONCAVE_POLYGON = 2;
+  public static final int CONCAVE_POLYGON_WITH_HOLES = 3;
+  public static final int COMPLEX_POLYGON = 4;
+  public static final int CIRCLE = 5;
+  public static final int RECTANGLE = 6;
+  public static final int PATH = 7;
+  public static final int COLLECTION = 8;
+  public static final int POINT = 9;
+  public static final int LINE = 10;
+  public static final int EXACT_CIRCLE = 11;
 
   /* Helper shapes for generating constraints whch are just three sided polygons */
-  protected static final int CONVEX_SIMPLE_POLYGON = 500;
-  protected static final int CONCAVE_SIMPLE_POLYGON = 501;
+  public static final int CONVEX_SIMPLE_POLYGON = 500;
+  public static final int CONCAVE_SIMPLE_POLYGON = 501;
 
   /** Static methods only. */
   private RandomGeo3dShapeGenerator() {}
@@ -550,7 +567,7 @@ public final class RandomGeo3dShapeGenerator {
           collection.addShape(member);
         }
       }
-      if (collection.shapes.size() == 0) {
+      if (collection.size() == 0) {
         continue;
       }
       return collection;
@@ -963,7 +980,7 @@ public final class RandomGeo3dShapeGenerator {
    * @param points The points to order.
    * @return The list of ordered points anti-clockwise.
    */
-  protected static List<GeoPoint> orderPoints(List<GeoPoint> points) {
+  public static List<GeoPoint> orderPoints(List<GeoPoint> points) {
     double x = 0;
     double y = 0;
     double z = 0;
@@ -1005,8 +1022,7 @@ public final class RandomGeo3dShapeGenerator {
    * Class that holds the constraints that are given to build shapes. It consists in a list of
    * GeoAreaShapes and relationships the new shape needs to satisfy.
    */
-  static class Constraints extends HashMap<GeoAreaShape, Integer> {
-
+  public static class Constraints extends HashMap<GeoAreaShape, Integer> {
     /**
      * Check if the shape is valid under the constraints.
      *
@@ -1052,7 +1068,7 @@ public final class RandomGeo3dShapeGenerator {
       // For GeoCompositeMembershipShape we only consider the first shape to help
       // converging
       if (relationship == GeoArea.WITHIN && shape instanceof GeoCompositeMembershipShape) {
-        shape = (((GeoCompositeMembershipShape) shape).shapes.get(0));
+        shape = (((GeoCompositeMembershipShape) shape).getShapes().get(0));
       }
       switch (relationship) {
         case GeoArea.DISJOINT:
diff --git a/lucene/core/build.gradle b/lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/package-info.java
similarity index 81%
copy from lucene/core/build.gradle
copy to lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/package-info.java
index 989c57f..004a77b 100644
--- a/lucene/core/build.gradle
+++ b/lucene/spatial-test-fixtures/src/java/org/apache/lucene/spatial3d/tests/package-info.java
@@ -15,11 +15,5 @@
  * limitations under the License.
  */
 
-apply plugin: 'java-library'
-
-description = 'Lucene core library'
-
-dependencies {
-  testImplementation project(':lucene:codecs')
-  testImplementation project(':lucene:test-framework')
-}
+/** Shared test fixtures for spatial packages. */
+package org.apache.lucene.spatial3d.tests;
diff --git a/lucene/spatial-test-fixtures/src/java/overview.html b/lucene/spatial-test-fixtures/src/java/overview.html
new file mode 100644
index 0000000..08e16bc
--- /dev/null
+++ b/lucene/spatial-test-fixtures/src/java/overview.html
@@ -0,0 +1,24 @@
+<!--
+ 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.
+-->
+<html>
+  <head>
+    <title>Apache Lucene Spatial Test Fixtures Module</title>
+  </head>
+  <body>
+    <h1>Internal test fixtures for spatial modules</h1>
+  </body>
+</html>
diff --git a/lucene/spatial3d/build.gradle b/lucene/spatial3d/build.gradle
index 219d029..0b25833 100644
--- a/lucene/spatial3d/build.gradle
+++ b/lucene/spatial3d/build.gradle
@@ -16,13 +16,17 @@
  */
 
 apply plugin: 'java-library'
-apply plugin: 'java-test-fixtures'
 
 description = '3D spatial planar geometry APIs'
 
 dependencies {
   moduleApi project(':lucene:core')
+  moduleTestImplementation project(':lucene:test-framework')
 
-  testFixturesApi project(':lucene:test-framework')
-  testImplementation project(':lucene:test-framework')
+  // We have to exclude ourselves because spatial-test-fixtures depend
+  // on the main source set of this project and tests would receive the
+  // dependency twice - on classpath and in module path.
+  moduleTestImplementation(project(':lucene:spatial-test-fixtures'), {
+    exclude module: "spatial3d"
+  })
 }
diff --git a/lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java b/lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java
index 8c87706..876882a 100644
--- a/lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java
+++ b/lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java
@@ -22,6 +22,7 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -149,4 +150,9 @@ public abstract class GeoBaseCompositeShape<T extends GeoShape> extends BasePlan
     GeoBaseCompositeShape<?> other = (GeoBaseCompositeShape<?>) o;
     return super.equals(other) && shapes.equals(other.shapes);
   }
+
+  /** Returns an unmodifiable list of composed shapes. */
+  public List<T> getShapes() {
+    return Collections.unmodifiableList(shapes);
+  }
 }
diff --git a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoExactCircle.java b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoExactCircle.java
index c99f602..28036e2 100644
--- a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoExactCircle.java
+++ b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoExactCircle.java
@@ -17,9 +17,10 @@
 
 package org.apache.lucene.spatial3d.geom;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 
 import com.carrotsearch.randomizedtesting.annotations.Repeat;
+import org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator;
 import org.apache.lucene.tests.util.LuceneTestCase;
 import org.junit.Test;
 
diff --git a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomBinaryCodec.java b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomBinaryCodec.java
index 214ef7d..3c0a6a8 100644
--- a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomBinaryCodec.java
+++ b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomBinaryCodec.java
@@ -17,7 +17,7 @@
 
 package org.apache.lucene.spatial3d.geom;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 
 import com.carrotsearch.randomizedtesting.annotations.Repeat;
 import java.io.ByteArrayInputStream;
diff --git a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoPolygon.java b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoPolygon.java
index e5a9d7d..ac87144 100644
--- a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoPolygon.java
+++ b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoPolygon.java
@@ -16,7 +16,7 @@
  */
 package org.apache.lucene.spatial3d.geom;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 
 import com.carrotsearch.randomizedtesting.generators.BiasedNumbers;
 import java.util.ArrayList;
diff --git a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoShapeRelationship.java b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoShapeRelationship.java
index 9f3bd75..9b63b4e 100644
--- a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoShapeRelationship.java
+++ b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomGeoShapeRelationship.java
@@ -17,7 +17,7 @@
 
 package org.apache.lucene.spatial3d.geom;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 
 import org.apache.lucene.tests.util.LuceneTestCase;
 import org.junit.Test;
diff --git a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomPlane.java b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomPlane.java
index 2ed0066..503188a 100644
--- a/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomPlane.java
+++ b/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestRandomPlane.java
@@ -17,7 +17,7 @@
 
 package org.apache.lucene.spatial3d.geom;
 
-import static org.apache.lucene.spatial3d.geom.RandomGeo3dShapeGenerator.*;
+import static org.apache.lucene.spatial3d.tests.RandomGeo3dShapeGenerator.*;
 
 import com.carrotsearch.randomizedtesting.annotations.Repeat;
 import java.util.ArrayList;
diff --git a/lucene/suggest/build.gradle b/lucene/suggest/build.gradle
index 3afccd8..25a444d 100644
--- a/lucene/suggest/build.gradle
+++ b/lucene/suggest/build.gradle
@@ -23,5 +23,5 @@ dependencies {
   moduleApi project(':lucene:core')
   moduleApi project(':lucene:analysis:common')
   
-  testImplementation project(':lucene:test-framework')
+  moduleTestImplementation project(':lucene:test-framework')
 }
diff --git a/lucene/test-framework/src/java/module-info.java b/lucene/test-framework/src/java/module-info.java
index 81ee062..d87d56a 100644
--- a/lucene/test-framework/src/java/module-info.java
+++ b/lucene/test-framework/src/java/module-info.java
@@ -16,12 +16,12 @@
  */
 
 /** Lucene test framework. */
-@SuppressWarnings({"module", "requires-automatic"})
+@SuppressWarnings({"module", "requires-automatic", "requires-transitive-automatic"})
 module org.apache.lucene.test_framework {
   requires org.apache.lucene.core;
   requires org.apache.lucene.codecs;
-  requires junit;
-  requires randomizedtesting.runner;
+  requires transitive junit;
+  requires transitive randomizedtesting.runner;
 
   exports org.apache.lucene.tests.analysis.standard;
   exports org.apache.lucene.tests.analysis;
diff --git a/settings.gradle b/settings.gradle
index 0923e9d..971fbb6 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -63,5 +63,6 @@ include "lucene:replicator"
 include "lucene:sandbox"
 include "lucene:spatial3d"
 include "lucene:spatial-extras"
+include "lucene:spatial-test-fixtures"
 include "lucene:suggest"
 include "lucene:test-framework"
diff --git a/versions.lock b/versions.lock
index c596e6e..fb12bb6 100644
--- a/versions.lock
+++ b/versions.lock
@@ -23,7 +23,7 @@ org.ow2.asm:asm-analysis:7.2 (1 constraints: e409d9a5)
 org.ow2.asm:asm-commons:7.2 (1 constraints: ad042e2c)
 org.ow2.asm:asm-tree:7.2 (2 constraints: 2f14468c)
 ua.net.nlp:morfologik-ukrainian-search:4.9.1 (1 constraints: 10051b36)
-xerces:xercesImpl:2.12.0 (2 constraints: 1f14b675)
+xerces:xercesImpl:2.12.0 (1 constraints: 3705353b)
 
 [Test dependencies]
 org.assertj:assertj-core:3.21.0 (1 constraints: 38053c3b)