Repository: junit-team/junit5 (language: java)

Issue:
Allow third parties to provide a custom `ClasspathScanner` implementation
The Jupiter and Vintage engines use `org.junit.platform.commons.util.ClasspathScanner` to discover test classes in the classpath. The `ClasspathScanner` scans directories and looks for class files. It works fine for a standard JVM process; however, it needs to be customized for a specific environment such as Android.

A possible solution for this is to make the `ClasspathScanner` an interface and load the implementation via the `ServiceLoader` mechanism.

```java
public interface ClasspathScanner {
	static ClasspathScanner getInstance() {
		ServiceLoader<ClasspathScanner> serviceLoader = ServiceLoader.load(ClasspathScanner.class);
		for (ClasspathScanner scanner : serviceLoader) {
			return scanner;
		}
		return new DefaultClasspathScanner(ClassLoaderUtils::getDefaultClassLoader, ReflectionUtils::tryToLoadClass);
	}

	List<Class<?>> scanForClassesInPackage(String basePackageName, ClassFilter classFilter);

	List<Class<?>> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter);
}
```

Then, we could provide the Android-specific implementation with [`androidx.test.internal.runner.ClassPathScanner`](https://github.com/android/android-test/blob/main/runner/android_junit_runner/java/androidx/test/internal/runner/ClassPathScanner.java), for example.

This change would make the Jupiter and Vintage engines work in Android.


Discussion / hints:
Thanks for the proposal! It'd be awesome to get Jupiter and Vintage working out-of-the-box on Android. I'll discuss it with the team and will get back to you.
SGTM.

Let's put that interface in the `org.junit.platform.commons.support.scanning` package.
**Team decision:** Let's follow the proposal but wait for #3630 to be done first.

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
index 90112bdc7455..4b3b26d38b76 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc
@@ -55,6 +55,8 @@ JUnit repository on GitHub.
 * Output written to `System.out` and `System.err` from non-test threads is now attributed
   to the most recent test or container that was started or has written output.
 * Introduced contracts for Kotlin-specific assertion methods.
+* New public interface `ClasspathScanner` allowing third parties to provide a custom
+  implementation for scanning the classpath for classes and resources.
 
 
 [[release-notes-5.12.0-M1-junit-jupiter]]
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/function/package-info.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/function/package-info.java
index 5990d37bb869..8f031faf22e0 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/function/package-info.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/function/package-info.java
@@ -1,5 +1,5 @@
 /**
- * Maintained functional interfaces and support classes.
+ * Functional interfaces and support classes.
  */
 
 package org.junit.platform.commons.function;
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/DefaultResource.java
similarity index 63%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/DefaultResource.java
index af1944cc20e4..31e1a9eb9972 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathResource.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/DefaultResource.java
@@ -8,22 +8,33 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support;
+
+import static org.apiguardian.api.API.Status.INTERNAL;
 
 import java.net.URI;
 import java.util.Objects;
 
-import org.junit.platform.commons.support.Resource;
+import org.apiguardian.api.API;
+import org.junit.platform.commons.util.Preconditions;
+import org.junit.platform.commons.util.ToStringBuilder;
 
 /**
+ * <h2>DISCLAIMER</h2>
+ *
+ * <p>These utilities are intended solely for usage within the JUnit framework
+ * itself. <strong>Any usage by external parties is not supported.</strong>
+ * Use at your own risk!
+ *
  * @since 1.11
  */
-class ClasspathResource implements Resource {
+@API(status = INTERNAL, since = "1.12")
+public class DefaultResource implements Resource {
 
 	private final String name;
 	private final URI uri;
 
-	ClasspathResource(String name, URI uri) {
+	public DefaultResource(String name, URI uri) {
 		this.name = Preconditions.notNull(name, "name must not be null");
 		this.uri = Preconditions.notNull(uri, "uri must not be null");
 	}
@@ -44,7 +55,7 @@ public boolean equals(Object o) {
 			return true;
 		if (o == null || getClass() != o.getClass())
 			return false;
-		ClasspathResource that = (ClasspathResource) o;
+		DefaultResource that = (DefaultResource) o;
 		return name.equals(that.name) && uri.equals(that.uri);
 	}
 
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/package-info.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/package-info.java
index e51977179941..18807d6a4e90 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/package-info.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/package-info.java
@@ -1,5 +1,5 @@
 /**
- * Maintained conversion APIs provided by the JUnit Platform.
+ * Conversion APIs provided by the JUnit Platform.
  */
 
 package org.junit.platform.commons.support.conversion;
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/package-info.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/package-info.java
index 6e9c460116f4..fae0c2a81547 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/package-info.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/package-info.java
@@ -1,5 +1,5 @@
 /**
- * Maintained common support APIs provided by the JUnit Platform.
+ * Common support APIs provided by the JUnit Platform.
  *
  * <p>The purpose of this package is to provide {@code TestEngine} and
  * {@code Extension} authors convenient access to a subset of internal utility
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFilter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClassFilter.java
similarity index 59%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFilter.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClassFilter.java
index 8b0728213055..4e222c89f683 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClassFilter.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClassFilter.java
@@ -8,30 +8,28 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
-import static org.apiguardian.api.API.Status.INTERNAL;
+import static org.apiguardian.api.API.Status.EXPERIMENTAL;
 
 import java.util.function.Predicate;
 
 import org.apiguardian.api.API;
+import org.junit.platform.commons.util.Preconditions;
 
 /**
  * Class-related predicate used by reflection utilities.
  *
- * <h2>DISCLAIMER</h2>
- *
- * <p>These utilities are intended solely for usage within the JUnit framework
- * itself. <strong>Any usage by external parties is not supported.</strong>
- * Use at your own risk!
- *
  * @since 1.1
  */
-@API(status = INTERNAL, since = "1.1")
-public class ClassFilter implements Predicate<Class<?>> {
+@API(status = EXPERIMENTAL, since = "1.12")
+public class ClassFilter {
 
 	/**
 	 * Create a {@link ClassFilter} instance that accepts all names but filters classes.
+	 *
+	 * @param classPredicate the class type predicate; never {@code null}
+	 * @return an instance of {@code ClassFilter}; never {@code null}
 	 */
 	public static ClassFilter of(Predicate<Class<?>> classPredicate) {
 		return of(name -> true, classPredicate);
@@ -39,6 +37,10 @@ public static ClassFilter of(Predicate<Class<?>> classPredicate) {
 
 	/**
 	 * Create a {@link ClassFilter} instance that filters by names and classes.
+	 *
+	 * @param namePredicate the class name predicate; never {@code null}
+	 * @param classPredicate the class type predicate; never {@code null}
+	 * @return an instance of {@code ClassFilter}; never {@code null}
 	 */
 	public static ClassFilter of(Predicate<String> namePredicate, Predicate<Class<?>> classPredicate) {
 		return new ClassFilter(namePredicate, classPredicate);
@@ -53,26 +55,25 @@ private ClassFilter(Predicate<String> namePredicate, Predicate<Class<?>> classPr
 	}
 
 	/**
-	 * Test name using the stored name predicate.
+	 * Test the given name using the stored name predicate.
+	 *
+	 * @param name the name to test; never {@code null}
+	 * @return {@code true} if the input name matches the predicate, otherwise
+	 * {@code false}
 	 */
 	public boolean match(String name) {
 		return namePredicate.test(name);
 	}
 
 	/**
-	 * Test class using the stored class predicate.
+	 * Test the given class using the stored class predicate.
+	 *
+	 * @param type the type to test; never {@code null}
+	 * @return {@code true} if the input type matches the predicate, otherwise
+	 * {@code false}
 	 */
 	public boolean match(Class<?> type) {
 		return classPredicate.test(type);
 	}
 
-	/**
-	 * @implNote This implementation combines all tests stored in the predicates
-	 * of this instance. Any new predicate must be added to this test method as
-	 * well.
-	 */
-	@Override
-	public boolean test(Class<?> type) {
-		return match(type.getName()) && match(type);
-	}
 }
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFileVisitor.java
similarity index 97%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFileVisitor.java
index 7f2ad8195085..e29a6c11cac2 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFileVisitor.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFileVisitor.java
@@ -8,7 +8,7 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import static java.nio.file.FileVisitResult.CONTINUE;
 
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFilters.java
similarity index 95%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFilters.java
index 7ad6cd3b0682..ea461d7d6abe 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathFilters.java
@@ -8,7 +8,7 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import java.nio.file.Path;
 import java.util.function.Predicate;
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathScanner.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathScanner.java
new file mode 100644
index 000000000000..ed1daabf2c69
--- /dev/null
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/ClasspathScanner.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2015-2024 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.commons.support.scanning;
+
+import static org.apiguardian.api.API.Status;
+
+import java.net.URI;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.apiguardian.api.API;
+import org.junit.platform.commons.support.Resource;
+
+/**
+ * {@code ClasspathScanner} allows to scan the classpath for classes and
+ * resources.
+ *
+ * <p>An implementation of this interface can be registered via the
+ * {@link java.util.ServiceLoader ServiceLoader} mechanism.
+ *
+ * @since 1.12
+ */
+@API(status = Status.EXPERIMENTAL, since = "1.12")
+public interface ClasspathScanner {
+
+	/**
+	 * Find all {@linkplain Class classes} in the supplied classpath {@code root}
+	 * that match the specified {@code classFilter} filter.
+	 *
+	 * <p>The classpath scanning algorithm searches recursively in subpackages
+	 * beginning with the root of the classpath.
+	 *
+	 * @param basePackageName the name of the base package in which to start
+	 * scanning; must not be {@code null} and must be valid in terms of Java
+	 * syntax
+	 * @param classFilter the class type filter; never {@code null}
+	 * @return a list of all such classes found; never {@code null}
+	 * but potentially empty
+	 */
+	List<Class<?>> scanForClassesInPackage(String basePackageName, ClassFilter classFilter);
+
+	/**
+	 * Find all {@linkplain Class classes} in the supplied classpath {@code root}
+	 * that match the specified {@code classFilter} filter.
+	 *
+	 * <p>The classpath scanning algorithm searches recursively in subpackages
+	 * beginning with the root of the classpath.
+	 *
+	 * @param root the URI for the classpath root in which to scan; never
+	 * {@code null}
+	 * @param classFilter the class type filter; never {@code null}
+	 * @return a list of all such classes found; never {@code null}
+	 * but potentially empty
+	 */
+	List<Class<?>> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter);
+
+	/**
+	 * Find all {@linkplain Resource resources} in the supplied classpath {@code root}
+	 * that match the specified {@code resourceFilter} predicate.
+	 *
+	 * <p>The classpath scanning algorithm searches recursively in subpackages
+	 * beginning with the root of the classpath.
+	 *
+	 * @param basePackageName the name of the base package in which to start
+	 * scanning; must not be {@code null} and must be valid in terms of Java
+	 * syntax
+	 * @param resourceFilter the resource type filter; never {@code null}
+	 * @return a list of all such resources found; never {@code null}
+	 * but potentially empty
+	 */
+	List<Resource> scanForResourcesInPackage(String basePackageName, Predicate<Resource> resourceFilter);
+
+	/**
+	 * Find all {@linkplain Resource resources} in the supplied classpath {@code root}
+	 * that match the specified {@code resourceFilter} predicate.
+	 *
+	 * <p>The classpath scanning algorithm searches recursively in subpackages
+	 * beginning with the root of the classpath.
+	 *
+	 * @param root the URI for the classpath root in which to scan; never
+	 * {@code null}
+	 * @param resourceFilter the resource type filter; never {@code null}
+	 * @return a list of all such resources found; never {@code null}
+	 * but potentially empty
+	 */
+	List<Resource> scanForResourcesInClasspathRoot(URI root, Predicate<Resource> resourceFilter);
+
+}
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CloseablePath.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/CloseablePath.java
similarity index 98%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/CloseablePath.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/CloseablePath.java
index c1da5bacd819..d299a3a7a495 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CloseablePath.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/CloseablePath.java
@@ -8,7 +8,7 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import static java.util.Collections.emptyMap;
 
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/DefaultClasspathScanner.java
similarity index 89%
rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java
rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/DefaultClasspathScanner.java
index 19bec125b93c..0d0c04eeee0a 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScanner.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/DefaultClasspathScanner.java
@@ -8,13 +8,14 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import static java.lang.String.format;
 import static java.util.Collections.emptyList;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static org.junit.platform.commons.util.ClasspathFilters.CLASS_FILE_SUFFIX;
+import static org.apiguardian.api.API.Status.INTERNAL;
+import static org.junit.platform.commons.support.scanning.ClasspathFilters.CLASS_FILE_SUFFIX;
 import static org.junit.platform.commons.util.StringUtils.isNotBlank;
 
 import java.io.IOException;
@@ -35,11 +36,16 @@
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
+import org.apiguardian.api.API;
 import org.junit.platform.commons.PreconditionViolationException;
 import org.junit.platform.commons.function.Try;
 import org.junit.platform.commons.logging.Logger;
 import org.junit.platform.commons.logging.LoggerFactory;
+import org.junit.platform.commons.support.DefaultResource;
 import org.junit.platform.commons.support.Resource;
+import org.junit.platform.commons.util.PackageUtils;
+import org.junit.platform.commons.util.Preconditions;
+import org.junit.platform.commons.util.UnrecoverableExceptions;
 
 /**
  * <h2>DISCLAIMER</h2>
@@ -50,9 +56,10 @@
  *
  * @since 1.0
  */
-class ClasspathScanner {
+@API(status = INTERNAL, since = "1.12")
+public class DefaultClasspathScanner implements ClasspathScanner {
 
-	private static final Logger logger = LoggerFactory.getLogger(ClasspathScanner.class);
+	private static final Logger logger = LoggerFactory.getLogger(DefaultClasspathScanner.class);
 
 	private static final char CLASSPATH_RESOURCE_PATH_SEPARATOR = '/';
 	private static final String CLASSPATH_RESOURCE_PATH_SEPARATOR_STRING = String.valueOf(
@@ -69,14 +76,15 @@ class ClasspathScanner {
 
 	private final BiFunction<String, ClassLoader, Try<Class<?>>> loadClass;
 
-	ClasspathScanner(Supplier<ClassLoader> classLoaderSupplier,
+	public DefaultClasspathScanner(Supplier<ClassLoader> classLoaderSupplier,
 			BiFunction<String, ClassLoader, Try<Class<?>>> loadClass) {
 
 		this.classLoaderSupplier = classLoaderSupplier;
 		this.loadClass = loadClass;
 	}
 
-	List<Class<?>> scanForClassesInPackage(String basePackageName, ClassFilter classFilter) {
+	@Override
+	public List<Class<?>> scanForClassesInPackage(String basePackageName, ClassFilter classFilter) {
 		Preconditions.condition(
 			PackageUtils.DEFAULT_PACKAGE_NAME.equals(basePackageName) || isNotBlank(basePackageName),
 			"basePackageName must not be null or blank");
@@ -87,14 +95,16 @@ List<Class<?>> scanForClassesInPackage(String basePackageName, ClassFilter class
 		return findClassesForUris(roots, basePackageName, classFilter);
 	}
 
-	List<Class<?>> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter) {
+	@Override
+	public List<Class<?>> scanForClassesInClasspathRoot(URI root, ClassFilter classFilter) {
 		Preconditions.notNull(root, "root must not be null");
 		Preconditions.notNull(classFilter, "classFilter must not be null");
 
 		return findClassesForUri(root, PackageUtils.DEFAULT_PACKAGE_NAME, classFilter);
 	}
 
-	List<Resource> scanForResourcesInPackage(String basePackageName, Predicate<Resource> resourceFilter) {
+	@Override
+	public List<Resource> scanForResourcesInPackage(String basePackageName, Predicate<Resource> resourceFilter) {
 		Preconditions.condition(
 			PackageUtils.DEFAULT_PACKAGE_NAME.equals(basePackageName) || isNotBlank(basePackageName),
 			"basePackageName must not be null or blank");
@@ -105,7 +115,8 @@ List<Resource> scanForResourcesInPackage(String basePackageName, Predicate<Resou
 		return findResourcesForUris(roots, basePackageName, resourceFilter);
 	}
 
-	List<Resource> scanForResourcesInClasspathRoot(URI root, Predicate<Resource> resourceFilter) {
+	@Override
+	public List<Resource> scanForResourcesInClasspathRoot(URI root, Predicate<Resource> resourceFilter) {
 		Preconditions.notNull(root, "root must not be null");
 		Preconditions.notNull(resourceFilter, "resourceFilter must not be null");
 
@@ -188,8 +199,7 @@ private void processClassFileSafely(Path baseDir, String basePackageName, ClassF
 					// @formatter:off
 					loadClass.apply(fullyQualifiedClassName, getClassLoader())
 							.toOptional()
-							// Always use ".filter(classFilter)" to include future predicates.
-							.filter(classFilter)
+							.filter(classFilter::match)
 							.ifPresent(classConsumer);
 					// @formatter:on
 				}
@@ -208,7 +218,7 @@ private void processResourceFileSafely(Path baseDir, String basePackageName, Pre
 		try {
 			String fullyQualifiedResourceName = determineFullyQualifiedResourceName(baseDir, basePackageName,
 				resourceFile);
-			Resource resource = new ClasspathResource(fullyQualifiedResourceName, resourceFile.toUri());
+			Resource resource = new DefaultResource(fullyQualifiedResourceName, resourceFile.toUri());
 			if (resourceFilter.test(resource)) {
 				resourceConsumer.accept(resource);
 			}
@@ -309,7 +319,7 @@ private List<URI> getRootUrisForPackageNameOnClassPathAndModulePath(String baseP
 		Set<URI> uriSet = new LinkedHashSet<>(getRootUrisForPackage(basePackageName));
 		if (!basePackageName.isEmpty() && !basePackageName.endsWith(PACKAGE_SEPARATOR_STRING)) {
 			getRootUrisForPackage(basePackageName + PACKAGE_SEPARATOR_STRING).stream() //
-					.map(ClasspathScanner::removeTrailingClasspathResourcePathSeparator) //
+					.map(DefaultClasspathScanner::removeTrailingClasspathResourcePathSeparator) //
 					.forEach(uriSet::add);
 		}
 		return new ArrayList<>(uriSet);
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/package-info.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/package-info.java
new file mode 100644
index 000000000000..769733a65aef
--- /dev/null
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/scanning/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Classpath scanning APIs provided by the JUnit Platform.
+ */
+
+package org.junit.platform.commons.support.scanning;
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScannerLoader.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScannerLoader.java
new file mode 100644
index 000000000000..6a2240815605
--- /dev/null
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathScannerLoader.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2015-2024 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.commons.util;
+
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.StreamSupport.stream;
+
+import java.util.List;
+import java.util.ServiceLoader;
+
+import org.junit.platform.commons.JUnitException;
+import org.junit.platform.commons.support.scanning.ClasspathScanner;
+import org.junit.platform.commons.support.scanning.DefaultClasspathScanner;
+
+/**
+ * @since 1.12
+ */
+class ClasspathScannerLoader {
+
+	static ClasspathScanner getInstance() {
+		ServiceLoader<ClasspathScanner> serviceLoader = ServiceLoader.load(ClasspathScanner.class,
+			ClassLoaderUtils.getDefaultClassLoader());
+
+		List<ClasspathScanner> classpathScanners = stream(serviceLoader.spliterator(), false).collect(toList());
+
+		if (classpathScanners.size() == 1) {
+			return classpathScanners.get(0);
+		}
+
+		if (classpathScanners.size() > 1) {
+			throw new JUnitException(String.format(
+				"There should not be more than one ClasspathScanner implementation present on the classpath but there were %d: %s",
+				classpathScanners.size(), classpathScanners));
+		}
+
+		return new DefaultClasspathScanner(ClassLoaderUtils::getDefaultClassLoader, ReflectionUtils::tryToLoadClass);
+	}
+
+}
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
index d24b977d71eb..54da1d85af11 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
@@ -23,6 +23,7 @@
 import org.junit.platform.commons.logging.Logger;
 import org.junit.platform.commons.logging.LoggerFactory;
 import org.junit.platform.commons.support.Resource;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 
 /**
  * Collection of utilities for working with {@code java.lang.Module}
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/PackageUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/PackageUtils.java
index 6b23a5324bac..e83f42af02f4 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/PackageUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/PackageUtils.java
@@ -40,7 +40,7 @@ private PackageUtils() {
 		/* no-op */
 	}
 
-	static final String DEFAULT_PACKAGE_NAME = "";
+	public static final String DEFAULT_PACKAGE_NAME = "";
 
 	/**
 	 * Get the package attribute for the supplied {@code type} using the
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java
index 2dafd46d5344..3f8a23d2857d 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ReflectionUtils.java
@@ -62,7 +62,10 @@
 import org.junit.platform.commons.function.Try;
 import org.junit.platform.commons.logging.Logger;
 import org.junit.platform.commons.logging.LoggerFactory;
+import org.junit.platform.commons.support.DefaultResource;
 import org.junit.platform.commons.support.Resource;
+import org.junit.platform.commons.support.scanning.ClassFilter;
+import org.junit.platform.commons.support.scanning.ClasspathScanner;
 
 /**
  * Collection of utilities for working with the Java reflection APIs.
@@ -148,8 +151,7 @@ public enum HierarchyTraversalMode {
 
 	private static final Class<?>[] EMPTY_CLASS_ARRAY = new Class<?>[0];
 
-	private static final ClasspathScanner classpathScanner = new ClasspathScanner(
-		ClassLoaderUtils::getDefaultClassLoader, ReflectionUtils::tryToLoadClass);
+	private static final ClasspathScanner classpathScanner = ClasspathScannerLoader.getInstance();
 
 	/**
 	 * Cache for equivalent methods on an interface implemented by the declaring class.
@@ -935,7 +937,7 @@ public static Try<Set<Resource>> tryToGetResources(String classpathResourceName,
 			List<URL> resources = Collections.list(classLoader.getResources(canonicalClasspathResourceName));
 			return resources.stream().map(url -> {
 				try {
-					return new ClasspathResource(canonicalClasspathResourceName, url.toURI());
+					return new DefaultResource(canonicalClasspathResourceName, url.toURI());
 				}
 				catch (URISyntaxException e) {
 					throw ExceptionUtils.throwAsUncheckedException(e);
diff --git a/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java b/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java
index f3a6bd1a5f33..fe83af684518 100644
--- a/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java
+++ b/junit-platform-commons/src/main/java9/org/junit/platform/commons/util/ModuleUtils.java
@@ -37,7 +37,9 @@
 import org.junit.platform.commons.JUnitException;
 import org.junit.platform.commons.logging.Logger;
 import org.junit.platform.commons.logging.LoggerFactory;
+import org.junit.platform.commons.support.DefaultResource;
 import org.junit.platform.commons.support.Resource;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 
 /**
  * Collection of utilities for working with {@code java.lang.Module}
@@ -225,8 +227,7 @@ List<Class<?>> scan(ModuleReference reference) {
 							.filter(name -> !name.equals("module-info"))
 							.filter(classFilter::match)
 							.map(this::loadClassUnchecked)
-							// Always use ".filter(classFilter)" to include future predicates.
-							.filter(classFilter)
+							.filter(classFilter::match)
 							.collect(Collectors.toList());
 					// @formatter:on
 				}
@@ -298,7 +299,7 @@ List<Resource> scan(ModuleReference reference) {
 		private Resource loadResourceUnchecked(String binaryName) {
 			try {
 				URI uri = classLoader.getResource(binaryName).toURI();
-				return new ClasspathResource(binaryName, uri);
+				return new DefaultResource(binaryName, uri);
 			}
 			catch (URISyntaxException e) {
 				throw new JUnitException("Failed to load resource with name '" + binaryName + "'.", e);
diff --git a/junit-platform-commons/src/module/org.junit.platform.commons/module-info.java b/junit-platform-commons/src/module/org.junit.platform.commons/module-info.java
index 774684198f9f..fb3fdba07a68 100644
--- a/junit-platform-commons/src/module/org.junit.platform.commons/module-info.java
+++ b/junit-platform-commons/src/module/org.junit.platform.commons/module-info.java
@@ -37,6 +37,7 @@
 			org.junit.vintage.engine;
 	exports org.junit.platform.commons.support;
 	exports org.junit.platform.commons.support.conversion;
+	exports org.junit.platform.commons.support.scanning;
 	exports org.junit.platform.commons.util to
 			org.junit.jupiter.api,
 			org.junit.jupiter.engine,
@@ -52,4 +53,5 @@
 			org.junit.platform.suite.engine,
 			org.junit.platform.testkit,
 			org.junit.vintage.engine;
+	uses org.junit.platform.commons.support.scanning.ClasspathScanner;
 }
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/filter/ClasspathScanningSupport.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/filter/ClasspathScanningSupport.java
index e3d2b0392cb2..12361024de15 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/filter/ClasspathScanningSupport.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/filter/ClasspathScanningSupport.java
@@ -18,7 +18,7 @@
 import java.util.function.Predicate;
 
 import org.apiguardian.api.API;
-import org.junit.platform.commons.util.ClassFilter;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 import org.junit.platform.engine.DiscoveryFilter;
 import org.junit.platform.engine.EngineDiscoveryRequest;
 import org.junit.platform.engine.discovery.ClassNameFilter;
diff --git a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/ClassSelectorResolver.java b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/ClassSelectorResolver.java
index e1bbc8834e78..8f160a2152f1 100644
--- a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/ClassSelectorResolver.java
+++ b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/ClassSelectorResolver.java
@@ -18,7 +18,7 @@
 
 import org.junit.platform.commons.JUnitException;
 import org.junit.platform.commons.support.ReflectionSupport;
-import org.junit.platform.commons.util.ClassFilter;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 import org.junit.platform.engine.TestDescriptor;
 import org.junit.platform.engine.UniqueId;
 import org.junit.platform.engine.UniqueId.Segment;
@@ -43,7 +43,10 @@ class ClassSelectorResolver implements SelectorResolver {
 
 	@Override
 	public Resolution resolve(ClassSelector selector, Context context) {
-		return resolveTestClass(selector.getJavaClass(), context);
+		if (classFilter.match(selector.getClassName())) {
+			return resolveTestClassThatPassedNameFilter(selector.getJavaClass(), context);
+		}
+		return unresolved();
 	}
 
 	@Override
@@ -51,15 +54,17 @@ public Resolution resolve(UniqueIdSelector selector, Context context) {
 		Segment lastSegment = selector.getUniqueId().getLastSegment();
 		if (SEGMENT_TYPE_RUNNER.equals(lastSegment.getType())) {
 			String testClassName = lastSegment.getValue();
-			Class<?> testClass = ReflectionSupport.tryToLoadClass(testClassName)//
-					.getOrThrow(cause -> new JUnitException("Unknown class: " + testClassName, cause));
-			return resolveTestClass(testClass, context);
+			if (classFilter.match(testClassName)) {
+				Class<?> testClass = ReflectionSupport.tryToLoadClass(testClassName)//
+						.getOrThrow(cause -> new JUnitException("Unknown class: " + testClassName, cause));
+				return resolveTestClassThatPassedNameFilter(testClass, context);
+			}
 		}
 		return unresolved();
 	}
 
-	private Resolution resolveTestClass(Class<?> testClass, Context context) {
-		if (!classFilter.test(testClass)) {
+	private Resolution resolveTestClassThatPassedNameFilter(Class<?> testClass, Context context) {
+		if (!classFilter.match(testClass)) {
 			return unresolved();
 		}
 		Runner runner = RUNNER_BUILDER.safeRunnerForClass(testClass);
diff --git a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/VintageDiscoverer.java b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/VintageDiscoverer.java
index a8868df3abd4..ce67578616e6 100644
--- a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/VintageDiscoverer.java
+++ b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/discovery/VintageDiscoverer.java
@@ -13,7 +13,7 @@
 import static org.apiguardian.api.API.Status.INTERNAL;
 
 import org.apiguardian.api.API;
-import org.junit.platform.commons.util.ClassFilter;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 import org.junit.platform.engine.EngineDiscoveryRequest;
 import org.junit.platform.engine.TestDescriptor;
 import org.junit.platform.engine.UniqueId;


Test patch:
diff --git a/platform-tests/src/test/java/org/junit/platform/commons/util/CloseablePathTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/scanning/CloseablePathTests.java
similarity index 90%
rename from platform-tests/src/test/java/org/junit/platform/commons/util/CloseablePathTests.java
rename to platform-tests/src/test/java/org/junit/platform/commons/support/scanning/CloseablePathTests.java
index 512eefeb41ec..ecac16c34c01 100644
--- a/platform-tests/src/test/java/org/junit/platform/commons/util/CloseablePathTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/commons/support/scanning/CloseablePathTests.java
@@ -8,12 +8,11 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.platform.commons.test.ConcurrencyTestingUtils.executeConcurrently;
-import static org.junit.platform.commons.util.CloseablePath.JAR_URI_SCHEME;
+import static org.junit.platform.commons.support.scanning.CloseablePath.JAR_URI_SCHEME;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.only;
@@ -32,7 +31,8 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.platform.commons.util.CloseablePath.FileSystemProvider;
+import org.junit.platform.commons.support.scanning.CloseablePath.FileSystemProvider;
+import org.junit.platform.commons.test.ConcurrencyTestingUtils;
 import org.junit.platform.engine.support.hierarchical.OpenTest4JAwareThrowableCollector;
 
 class CloseablePathTests {
@@ -92,7 +92,8 @@ void createsAndClosesJarFileSystemOnceWhenCalledConcurrently() throws Exception
 		when(fileSystemProvider.newFileSystem(any())) //
 				.thenAnswer(invocation -> FileSystems.newFileSystem((URI) invocation.getArgument(0), Map.of()));
 
-		paths = executeConcurrently(numThreads, () -> CloseablePath.create(uri, fileSystemProvider));
+		paths = ConcurrencyTestingUtils.executeConcurrently(numThreads,
+			() -> CloseablePath.create(uri, fileSystemProvider));
 		verify(fileSystemProvider, only()).newFileSystem(jarUri);
 
 		// Close all but the first path
diff --git a/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/scanning/DefaultClasspathScannerTests.java
similarity index 90%
rename from platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java
rename to platform-tests/src/test/java/org/junit/platform/commons/support/scanning/DefaultClasspathScannerTests.java
index 7cd2f31456b4..1bcd92ce8b11 100644
--- a/platform-tests/src/test/java/org/junit/platform/commons/util/ClasspathScannerTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/commons/support/scanning/DefaultClasspathScannerTests.java
@@ -8,7 +8,7 @@
  * https://www.eclipse.org/legal/epl-v20.html
  */
 
-package org.junit.platform.commons.util;
+package org.junit.platform.commons.support.scanning;
 
 import static java.util.Objects.requireNonNull;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -50,14 +50,16 @@
 import org.junit.platform.commons.function.Try;
 import org.junit.platform.commons.logging.LogRecordListener;
 import org.junit.platform.commons.support.Resource;
+import org.junit.platform.commons.util.ClassLoaderUtils;
+import org.junit.platform.commons.util.ReflectionUtils;
 
 /**
- * Unit tests for {@link ClasspathScanner}.
+ * Unit tests for {@link DefaultClasspathScanner}.
  *
  * @since 1.0
  */
 @TrackLogRecords
-class ClasspathScannerTests {
+class DefaultClasspathScannerTests {
 
 	private static final ClassFilter allClasses = ClassFilter.of(type -> true);
 	private static final Predicate<Resource> allResources = type -> true;
@@ -67,8 +69,8 @@ class ClasspathScannerTests {
 	private final BiFunction<String, ClassLoader, Try<Class<?>>> trackingClassLoader = (name,
 			classLoader) -> ReflectionUtils.tryToLoadClass(name, classLoader).ifSuccess(loadedClasses::add);
 
-	private final ClasspathScanner classpathScanner = new ClasspathScanner(ClassLoaderUtils::getDefaultClassLoader,
-		trackingClassLoader);
+	private final DefaultClasspathScanner classpathScanner = new DefaultClasspathScanner(
+		ClassLoaderUtils::getDefaultClassLoader, trackingClassLoader);
 
 	@Test
 	void scanForClassesInClasspathRootWhenMalformedClassnameInternalErrorOccursWithNullDetailedMessage(
@@ -152,7 +154,7 @@ private void assertResourcesScannedWhenExceptionIsThrown(Predicate<Resource> fil
 
 	private void assertDebugMessageLogged(LogRecordListener listener, String regex) {
 		// @formatter:off
-		assertThat(listener.stream(ClasspathScanner.class, Level.FINE)
+		assertThat(listener.stream(DefaultClasspathScanner.class, Level.FINE)
 				.map(LogRecord::getMessage)
 				.filter(m -> m.matches(regex))
 		).hasSize(1);
@@ -187,7 +189,7 @@ private void scanForClassesInClasspathRootWithinJarFile(String resourceName) thr
 		var jarfile = getClass().getResource(resourceName);
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarfile }, null)) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var classes = classpathScanner.scanForClassesInClasspathRoot(jarfile.toURI(), allClasses);
 			assertThat(classes).extracting(Class::getName) //
@@ -211,7 +213,7 @@ private void scanForResourcesInClasspathRootWithinJarFile(String resourceName) t
 		var jarfile = getClass().getResource(resourceName);
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarfile }, null)) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var resources = classpathScanner.scanForResourcesInClasspathRoot(jarfile.toURI(), allResources);
 			assertThat(resources).extracting(Resource::getName) //
@@ -228,7 +230,7 @@ void scanForResourcesInShadowedClassPathRoot() throws Exception {
 		var shadowedJarFile = getClass().getResource("/jartest-shadowed.jar");
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarFile, shadowedJarFile }, null)) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var resources = classpathScanner.scanForResourcesInClasspathRoot(shadowedJarFile.toURI(), allResources);
 			assertThat(resources).extracting(Resource::getName).containsExactlyInAnyOrder(
@@ -238,7 +240,7 @@ void scanForResourcesInShadowedClassPathRoot() throws Exception {
 				"META-INF/MANIFEST.MF");
 
 			assertThat(resources).extracting(Resource::getUri) //
-					.map(ClasspathScannerTests::jarFileAndEntry) //
+					.map(DefaultClasspathScannerTests::jarFileAndEntry) //
 					.containsExactlyInAnyOrder(
 						// This resource only exists in the shadowed jar file
 						"jartest-shadowed.jar!/org/junit/platform/jartest/included/unique.resource",
@@ -256,13 +258,13 @@ void scanForResourcesInPackageWithDuplicateResources() throws Exception {
 		var shadowedJarFile = getClass().getResource("/jartest-shadowed.jar");
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarFile, shadowedJarFile }, null)) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var resources = classpathScanner.scanForResourcesInPackage("org.junit.platform.jartest.included",
 				allResources);
 
 			assertThat(resources).extracting(Resource::getUri) //
-					.map(ClasspathScannerTests::jarFileAndEntry) //
+					.map(DefaultClasspathScannerTests::jarFileAndEntry) //
 					.containsExactlyInAnyOrder(
 						// This resource only exists in the shadowed jar file
 						"jartest-shadowed.jar!/org/junit/platform/jartest/included/unique.resource",
@@ -329,7 +331,8 @@ private void checkModules2500(ModuleFinder finder) {
 		var parent = ClassLoader.getPlatformClassLoader();
 		var layer = ModuleLayer.defineModulesWithOneLoader(configuration, List.of(boot), parent).layer();
 
-		var classpathScanner = new ClasspathScanner(() -> layer.findLoader(root), ReflectionUtils::tryToLoadClass);
+		var classpathScanner = new DefaultClasspathScanner(() -> layer.findLoader(root),
+			ReflectionUtils::tryToLoadClass);
 		{
 			var classes = classpathScanner.scanForClassesInPackage("foo", allClasses);
 			var classNames = classes.stream().map(Class::getName).collect(Collectors.toList());
@@ -348,7 +351,7 @@ void findAllClassesInPackageWithinJarFileConcurrently() throws Exception {
 		var jarUri = URI.create("jar:" + jarFile);
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarFile })) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var results = executeConcurrently(10,
 				() -> classpathScanner.scanForClassesInPackage("org.junit.platform.jartest.included", allClasses));
@@ -369,7 +372,7 @@ void findAllResourcesInPackageWithinJarFileConcurrently() throws Exception {
 		var jarUri = URI.create("jar:" + jarFile);
 
 		try (var classLoader = new URLClassLoader(new URL[] { jarFile })) {
-			var classpathScanner = new ClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
+			var classpathScanner = new DefaultClasspathScanner(() -> classLoader, ReflectionUtils::tryToLoadClass);
 
 			var results = executeConcurrently(10,
 				() -> classpathScanner.scanForResourcesInPackage("org.junit.platform.jartest.included", allResources));
@@ -410,9 +413,9 @@ void scanForResourcesInDefaultPackage() {
 
 	@Test
 	void scanForClassesInPackageWithFilter() {
-		var thisClassOnly = ClassFilter.of(clazz -> clazz == ClasspathScannerTests.class);
+		var thisClassOnly = ClassFilter.of(clazz -> clazz == DefaultClasspathScannerTests.class);
 		var classes = classpathScanner.scanForClassesInPackage("org.junit.platform.commons", thisClassOnly);
-		assertSame(ClasspathScannerTests.class, classes.get(0));
+		assertSame(DefaultClasspathScannerTests.class, classes.get(0));
 	}
 
 	@Test
@@ -471,34 +474,34 @@ void scanForClassesInPackageForNullClassFilter() {
 
 	@Test
 	void scanForClassesInPackageWhenIOExceptionOccurs() {
-		var scanner = new ClasspathScanner(ThrowingClassLoader::new, ReflectionUtils::tryToLoadClass);
+		var scanner = new DefaultClasspathScanner(ThrowingClassLoader::new, ReflectionUtils::tryToLoadClass);
 		var classes = scanner.scanForClassesInPackage("org.junit.platform.commons", allClasses);
 		assertThat(classes).isEmpty();
 	}
 
 	@Test
 	void scanForResourcesInPackageWhenIOExceptionOccurs() {
-		var scanner = new ClasspathScanner(ThrowingClassLoader::new, ReflectionUtils::tryToLoadClass);
+		var scanner = new DefaultClasspathScanner(ThrowingClassLoader::new, ReflectionUtils::tryToLoadClass);
 		var classes = scanner.scanForResourcesInPackage("org.junit.platform.commons", allResources);
 		assertThat(classes).isEmpty();
 	}
 
 	@Test
 	void scanForClassesInPackageOnlyLoadsClassesThatAreIncludedByTheClassNameFilter() {
-		Predicate<String> classNameFilter = name -> ClasspathScannerTests.class.getName().equals(name);
+		Predicate<String> classNameFilter = name -> DefaultClasspathScannerTests.class.getName().equals(name);
 		var classFilter = ClassFilter.of(classNameFilter, type -> true);
 
 		classpathScanner.scanForClassesInPackage("org.junit.platform.commons", classFilter);
 
-		assertThat(loadedClasses).containsExactly(ClasspathScannerTests.class);
+		assertThat(loadedClasses).containsExactly(DefaultClasspathScannerTests.class);
 	}
 
 	@Test
 	void findAllClassesInClasspathRoot() throws Exception {
-		var thisClassOnly = ClassFilter.of(clazz -> clazz == ClasspathScannerTests.class);
+		var thisClassOnly = ClassFilter.of(clazz -> clazz == DefaultClasspathScannerTests.class);
 		var root = getTestClasspathRoot();
 		var classes = classpathScanner.scanForClassesInClasspathRoot(root, thisClassOnly);
-		assertSame(ClasspathScannerTests.class, classes.get(0));
+		assertSame(DefaultClasspathScannerTests.class, classes.get(0));
 	}
 
 	@Test
@@ -543,7 +546,7 @@ void findAllClassesInClasspathRootWithFilter() throws Exception {
 		var classes = classpathScanner.scanForClassesInClasspathRoot(root, allClasses);
 
 		assertThat(classes).hasSizeGreaterThanOrEqualTo(20);
-		assertTrue(classes.contains(ClasspathScannerTests.class));
+		assertTrue(classes.contains(DefaultClasspathScannerTests.class));
 	}
 
 	@Test
@@ -566,16 +569,17 @@ void findAllClassesInClasspathRootForNullClassFilter() {
 
 	@Test
 	void onlyLoadsClassesInClasspathRootThatAreIncludedByTheClassNameFilter() throws Exception {
-		var classFilter = ClassFilter.of(name -> ClasspathScannerTests.class.getName().equals(name), type -> true);
+		var classFilter = ClassFilter.of(name -> DefaultClasspathScannerTests.class.getName().equals(name),
+			type -> true);
 		var root = getTestClasspathRoot();
 
 		classpathScanner.scanForClassesInClasspathRoot(root, classFilter);
 
-		assertThat(loadedClasses).containsExactly(ClasspathScannerTests.class);
+		assertThat(loadedClasses).containsExactly(DefaultClasspathScannerTests.class);
 	}
 
 	private static URI uriOf(String name) {
-		var resource = ClasspathScannerTests.class.getResource(name);
+		var resource = DefaultClasspathScannerTests.class.getResource(name);
 		try {
 			return requireNonNull(resource).toURI();
 		}
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt
index cb3eb72ae947..11e66a7ca44f 100644
--- a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt
@@ -4,9 +4,11 @@ exports org.junit.platform.commons.annotation
 exports org.junit.platform.commons.function
 exports org.junit.platform.commons.support
 exports org.junit.platform.commons.support.conversion
+exports org.junit.platform.commons.support.scanning
 requires java.base mandated
 requires java.logging
 requires java.management
 requires org.apiguardian.api static transitive
+uses org.junit.platform.commons.support.scanning.ClasspathScanner
 qualified exports org.junit.platform.commons.logging to org.junit.jupiter.api org.junit.jupiter.engine org.junit.jupiter.migrationsupport org.junit.jupiter.params org.junit.platform.console org.junit.platform.engine org.junit.platform.launcher org.junit.platform.reporting org.junit.platform.runner org.junit.platform.suite.api org.junit.platform.suite.engine org.junit.platform.testkit org.junit.vintage.engine
 qualified exports org.junit.platform.commons.util to org.junit.jupiter.api org.junit.jupiter.engine org.junit.jupiter.migrationsupport org.junit.jupiter.params org.junit.platform.console org.junit.platform.engine org.junit.platform.launcher org.junit.platform.reporting org.junit.platform.runner org.junit.platform.suite.api org.junit.platform.suite.commons org.junit.platform.suite.engine org.junit.platform.testkit org.junit.vintage.engine
diff --git a/platform-tooling-support-tests/projects/multi-release-jar/src/test/java/integration/integration/ModuleUtilsTests.java b/platform-tooling-support-tests/projects/multi-release-jar/src/test/java/integration/integration/ModuleUtilsTests.java
index d4d961dcc251..c74a8e97c853 100644
--- a/platform-tooling-support-tests/projects/multi-release-jar/src/test/java/integration/integration/ModuleUtilsTests.java
+++ b/platform-tooling-support-tests/projects/multi-release-jar/src/test/java/integration/integration/ModuleUtilsTests.java
@@ -20,7 +20,7 @@
 
 import org.junit.jupiter.api.Test;
 import org.junit.platform.commons.PreconditionViolationException;
-import org.junit.platform.commons.util.ClassFilter;
+import org.junit.platform.commons.support.scanning.ClassFilter;
 import org.junit.platform.commons.util.ModuleUtils;
 
 /**