diff --git a/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java b/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java
index 64ea06b..27d9dff 100644
--- a/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java
+++ b/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java
@@ -12,6 +12,7 @@
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URL;
+import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
@@ -436,6 +437,52 @@ static PluginValidationResult verifyProviderApiLevels(List The type of the plugin, which extends the {@code Plugin} interface or class.
+ * @param className The fully qualified name of the class to load.
+ * @param pluginClass The expected class or interface that the plugin must extend or implement.
+ * @param url The URL pointing to the plugin's location (e.g., a jar file).
+ * @param parent The parent {@code ClassLoader} to use as a fallback during class loading.
+ * @return An instance of the loaded plugin class, cast to the specified plugin type.
+ * @throws ReflectiveOperationException If the class cannot be loaded, instantiated, or lacks a no-argument constructor.
+ * @throws IOException If an I/O error occurs while accessing the plugin's location.
+ * @throws IllegalStateException If the plugin class is resolved from the parent class loader instead of the provided URL.
+ * @throws IllegalArgumentException If the loaded class does not implement or extend the specified plugin type.
+ */
+ static T loadPluginClass(
+ String className,
+ Class pluginClass,
+ URL[] url,
+ ClassLoader parent
+ ) throws ReflectiveOperationException, IOException {
+ try (URLClassLoader loader = URLClassLoader.newInstance(url, parent)) {
+ // Load the class and initialize it right away.
+ // IMPORTANT: If the same FQCN is on the parent class loader path, the class would be retrieved from there, not the URL!
+ // As part of preload(), LoaderHelper.verifyNoClassCollisions() already made sure no collision exists.
+ Class> rawClass = Class.forName(className, false, loader);
+
+ // Just to be really sure, verify this is from the plugin, not the core
+ if (rawClass.getClassLoader() != loader) {
+ throw new IllegalStateException(
+ "Plugin class " + className + " resolved from parent instead of plugin source"
+ );
+ }
+
+ // Make sure the class is actually an implementation of the expected class
+ // (and the plugin descriptor didn't lie about it in preload checks)
+ if (!pluginClass.isAssignableFrom(rawClass)) {
+ throw new IllegalArgumentException("Plugin does not implement contract class");
+ }
+
+ // Try to get an actual instance of the class with the default noargs constructor
+ Class extends T> implClass = rawClass.asSubclass(pluginClass);
+ return implClass.getDeclaredConstructor().newInstance();
+ }
+ }
+
/**
* Converts a {@link SourcedDescriptor} and a plugin instance into a {@link PluginDescriptor}.
diff --git a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java
index edea2cd..f4469ef 100644
--- a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java
+++ b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java
@@ -20,7 +20,6 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.regex.PatternSyntaxException;
@@ -43,6 +42,14 @@
* It supports one-time classloading, but no reloading of changed JARs at runtime.
* An application restart is required to pick up changes to plugin JARs.
*
+ *
+ * It does not use the standard Java SPI {@link ServiceLoader} mechanism and relies solely on the
+ * plugin descriptor files generated by {@link io.gdcc.spi.meta.annotations.DataversePlugin} annotation processing.
+ * See {@link io.gdcc.spi.meta.descriptor.DescriptorFormat#DESCRIPTOR_DIRECTORY} for where these are stored
+ * in Dataverse Plugins.
+ * As a consequence, this loader will ignore older plugins for Dataverse that rely solely on the ServiceLoader mechanism.
+ * The Dataverse core may decide when to drop support for loading these.
+ *
*
* @param the type of plugin to load, constrained to implement the {@link Plugin} interface
*/
@@ -130,6 +137,7 @@ static void validatePluginBaseClass(Class> pluginClass) {
* Loads all plugins of type {@code T} from JAR files located in the specified directory.
* Each JAR file is loaded using a dedicated {@link URLClassLoader}, and plugins are
* discovered via the Java {@link ServiceLoader} mechanism (META-INF/services/package.plus.service.ClassName file).
+ *
*
* For each discovered plugin, its {@link Plugin#identity()} must be non-null and non-blank;
* otherwise, it is skipped and an error is recorded.
@@ -140,6 +148,11 @@ static void validatePluginBaseClass(Class> pluginClass) {
* @throws LoaderException if one or more errors occur during loading, if no plugins
* could be successfully loaded, or if there are any duplicates.
* Note: The exception may contain multiple causes, each associated with a specific file or failure point
+ * @apiNote The loader will not verify that any loaded plugin identities do not collide with identities loaded
+ * by other loaders from different locations or classpaths. It is up to the calling code to take care of this
+ * verification, allowing for flexibility how to deal with these potential collisions between plugins in external
+ * locations and/or ones shipped with the core. The members of this list of plugins returned by this function are
+ * guaranteed to be unique by identity and class name only among themselves.
*/
public List> load(Path pluginJarsLocation) {
@@ -251,8 +264,11 @@ List preloadPlugins(Set sources, SourceScanner scanner)
logger.debug("Scanning for non-implementations results: {}", implementationResult);
// 4. Verify that every plugin class has a service loader entry. Remove any affected from the list.
- var serviceProviderResult = LoaderHelper.verifyServiceProviderRecords(descriptors);
- logger.debug("Scanning for SPI record results: {}", serviceProviderResult);
+ // 2026-05-05 OB: To avoid class loading conflicts by the Java SPI default ServiceLoader,
+ // we now use our own, custom loader to initialize plugins.
+ // This check is no longer necessary but kept if we need to re-introduce it.
+ //var serviceProviderResult = LoaderHelper.verifyServiceProviderRecords(descriptors);
+ //logger.debug("Scanning for SPI record results: {}", serviceProviderResult);
// 5. Verify that the API level of the plugin matches the core-expected level(s).
var apiLevelResult = LoaderHelper.verifyPluginApiLevels(descriptors, this.pluginClass, this.parentClassLoader);
@@ -266,7 +282,7 @@ List preloadPlugins(Set sources, SourceScanner scanner)
var finalResults = PluginValidationResult.merge(
collisionResult,
implementationResult,
- serviceProviderResult,
+ //serviceProviderResult,
apiLevelResult,
providerLevelsResult
);
@@ -307,7 +323,8 @@ List preloadPlugins(Set sources, SourceScanner scanner)
* The returned map's keys describe the source of each loaded plugin via {@link PluginDescriptor},
* associating the plugin's logical identity, class name, and JAR file location. It is the
* caller's responsibility to verify no duplicates (by class name or identity) exist before
- * handing the plugins to the core.
+ * handing the plugins to the core. The members of this list of plugins returned by this function are
+ * guaranteed to be unique by identity and class name only among themselves.
*
* @param sources a mapping from (JAR) file paths to their corresponding URLs used for class loading
* @return a map from {@link PluginDescriptor} metadata to the corresponding plugin instance
@@ -319,37 +336,46 @@ List> load(List descriptors, Map
List sourceProblems = new ArrayList<>();
List> loadedPlugins = new ArrayList<>();
- // Create URLClassLoader for each file and load the plugin
- descriptors.forEach(descriptor -> {
+ // Create URLClassLoader for each file and load the plugin from the location
+ for (SourcedDescriptor descriptor : descriptors) {
URL[] sourceUrl = sources.get(descriptor.sourceLocation());
- try (URLClassLoader classLoader = URLClassLoader.newInstance(sourceUrl, this.parentClassLoader)) {
- // Load all plugins that can be found within the source for type T
- ServiceLoader loader = ServiceLoader.load(this.pluginClass, classLoader);
+ String pluginClassName = descriptor.plugin().klass();
+
+ try {
+ // Load the plugin from the source
+ T plugin = LoaderHelper.loadPluginClass(
+ pluginClassName,
+ this.pluginClass,
+ sourceUrl,
+ this.parentClassLoader
+ );
+
+ // Check for a valid identity being present, making the plugin identifiable once handed over to core
+ String identity = plugin.identity();
+ if (identity == null || identity.isBlank()) {
+ sourceProblems.add(new LoaderProblem.LocationFailure(
+ descriptor.sourceLocation(),
+ new IllegalArgumentException(pluginClassName + "'s identity cannot be null or blank")));
+ continue;
+ }
- // Iterate over all found plugins and add to the plugin map, including source information
- loader.forEach(plugin -> {
- String identity = plugin.identity();
- if (identity == null || identity.isBlank()) {
- sourceProblems.add(new LoaderProblem.LocationFailure(
- descriptor.sourceLocation(),
- new IllegalArgumentException(plugin.getClass().getCanonicalName() + "'s identity cannot be null or blank")));
- return;
- }
-
- // Save the plugin and its metadata to the set of already loaded plugins
- loadedPlugins.add(
- new PluginHandle<>(
- LoaderHelper.toPluginDescriptor(
- descriptor,
- plugin,
- this.parentClassLoader),
- plugin)
- );
- });
- } catch (IOException | NoSuchMethodError | ServiceConfigurationError | UnsupportedClassVersionError e) {
+ // Save the plugin and its metadata to the set of already loaded plugins
+ loadedPlugins.add(
+ new PluginHandle<>(
+ LoaderHelper.toPluginDescriptor(
+ descriptor,
+ plugin,
+ this.parentClassLoader),
+ plugin)
+ );
+ } catch (IllegalArgumentException e) {
+ sourceProblems.add(new LoaderProblem.PluginClassMismatch(pluginClassName, descriptor.sourceLocation(), pluginClass.getName()));
+ } catch (IllegalStateException e) {
+ sourceProblems.add(new LoaderProblem.PluginClassNameCollisionWithCore(pluginClassName, descriptor.sourceLocation()));
+ } catch ( ReflectiveOperationException | IOException e) {
sourceProblems.add(new LoaderProblem.LocationFailure(descriptor.sourceLocation(), e));
}
- });
+ }
logger.debug("Loader was able to load {} plugins from {} sources.", loadedPlugins.size(), sources.size());
// Make sure there are no duplicate plugin identities
diff --git a/core/src/main/java/io/gdcc/spi/core/loader/package-info.java b/core/src/main/java/io/gdcc/spi/core/loader/package-info.java
new file mode 100644
index 0000000..743b5fd
--- /dev/null
+++ b/core/src/main/java/io/gdcc/spi/core/loader/package-info.java
@@ -0,0 +1,148 @@
+/**
+ * Provides the runtime plugin loading facilities for the SPI-based plugin system.
+ *
+ * Overview
+ *
+ * This package is responsible for discovering, validating, and instantiating plugins
+ * for a specific base plugin contract. The central entry point is {@link io.gdcc.spi.core.loader.PluginLoader},
+ * which loads plugins from a filesystem location containing plugin artifacts such as JARs
+ * or exploded directories.
+ *
+ * The loader is intentionally validation-first:
+ *
+ * - It discovers candidate plugin sources from a given location.
+ * - It scans descriptor metadata from those sources.
+ * - It validates the metadata before plugin classes are instantiated.
+ * - It loads only the descriptors that passed validation, or those explicitly allowed
+ * by configuration as warning-level cases.
+ * - It returns loaded plugins as {@link io.gdcc.spi.core.loader.PluginHandle} instances, pairing the runtime
+ * plugin instance with its resolved {@link io.gdcc.spi.meta.descriptor.PluginDescriptor metadata}.
+ *
+ *
+ * This design keeps failure handling predictable and avoids loading plugin classes
+ * unnecessarily when metadata already shows that a plugin is incompatible.
+ *
+ * How the loader works
+ *
+ * A {@link io.gdcc.spi.core.loader.PluginLoader} is created for exactly one base plugin contract. That contract
+ * must be a valid plugin interface intended to act as the loader's target type.
+ *
+ * Calling {@link io.gdcc.spi.core.loader.PluginLoader#load(java.nio.file.Path)} performs two broad phases:
+ *
+ * 1. Preload and validation
+ *
+ * During preloading, the loader scans the configured sources and reads plugin descriptors.
+ * It then validates, among other things:
+ *
+ * - class name collisions between plugins,
+ * - class name collisions with classes already present in core,
+ * - whether a descriptor matches the requested base contract,
+ * - base contract API level compatibility,
+ * - required provider compatibility, and
+ * - identity uniqueness rules, depending on configuration.
+ *
+ *
+ * Validation problems are represented as {@link io.gdcc.spi.core.loader.LoaderProblem} instances. When the active
+ * {@link io.gdcc.spi.core.loader.LoaderConfiguration} is strict, incompatible plugins cause loading to abort with
+ * a {@link io.gdcc.spi.core.loader.LoaderException}. In more permissive configurations, some problems may be treated
+ * as warnings and the loader may continue with the remaining valid plugins.
+ *
+ * 2. Class loading and instantiation
+ *
+ * After successful prevalidation, plugin implementation classes are loaded from their
+ * corresponding source locations and instantiated. Each successfully loaded plugin is returned
+ * as a {@link io.gdcc.spi.core.loader.PluginHandle}, which gives access both to the instantiated plugin and to the
+ * resolved runtime descriptor.
+ *
+ * Applying configuration options
+ *
+ * Loader behavior is controlled through {@link io.gdcc.spi.core.loader.LoaderConfiguration}. The configuration
+ * type is immutable. The recommended style is to start from {@link io.gdcc.spi.core.loader.LoaderConfiguration#defaults()}
+ * and customize only the options that need to change.
+ *
+ * Typical configuration concerns include:
+ *
+ * - whether sources must contain only plugins matching the requested base contract,
+ * - whether mixed sources should emit warnings,
+ * - whether compatibility problems should abort loading, and
+ * - whether ambiguous or duplicated plugin identities should be rejected.
+ *
+ *
+ * Because configuration instances are immutable, they are easy to reuse, share, and test.
+ *
+ * Example: customizing loader behavior
+ *
+ * {@code
+ * LoaderConfiguration configuration = LoaderConfiguration.defaults()
+ * .withEnforceSingleSourceMatchingPluginsOnly(false)
+ * .withEmitWarningsOnMultiPluginSource(true)
+ * .withAbortOnCompatibilityProblems(false);
+ *
+ * PluginLoader loader =
+ * new PluginLoader<>(MyPluginContract.class, configuration);
+ * }
+ *
+ * What to expect
+ *
+ *
+ * - Early validation: many incompatibilities are detected from metadata
+ * before plugin classes are instantiated.
+ * - Aggregated diagnostics: failures are reported as collections of
+ * {@link io.gdcc.spi.core.loader.LoaderProblem problems} through {@link io.gdcc.spi.core.loader.LoaderException#getProblems()}.
+ * - Contract-focused loading: each loader instance targets one base
+ * plugin contract at a time.
+ * - Structured runtime results: successful loads are represented by
+ * {@link io.gdcc.spi.core.loader.PluginHandle} values rather than raw plugin instances alone.
+ * - Configurable strictness: callers can choose between fail-fast and
+ * more permissive behavior depending on operational needs.
+ *
+ *
+ * What not to expect
+ *
+ *
+ * - No hot reload: this package supports loading, not live reloading of
+ * changed plugin artifacts. Updated plugins generally require a fresh loading cycle and
+ * typically an application restart strategy at a higher level.
+ * - No legacy-plugin compatibility guarantee: the loader operates on the
+ * descriptor-based plugin model and does not aim to support older plugins that do not
+ * participate in that model.
+ * - No cross-loader identity coordination: identity uniqueness is checked
+ * within the scope of a loader run, not globally across every possible plugin source in
+ * an application.
+ * - No substitute for packaging discipline: plugin authors are still
+ * expected to provide correct metadata, compatible API levels, and non-conflicting
+ * classes and identities.
+ *
+ *
+ * Usage examples
+ *
+ * Example 1: Loading plugins with default settings
+ *
+ * {@code
+ * try {
+ * PluginLoader loader = new PluginLoader<>(MyPluginContract.class);
+ * List> plugins = loader.load(Path.of("plugins"));
+ *
+ * // Use or store plugins.
+ *
+ * } catch (LoaderException ex) {
+ * for (LoaderProblem problem : ex.getProblems()) {
+ * System.err.println(problem.message());
+ * }
+ * } catch (IllegalArgumentException ex) {
+ * System.err.println(ex.getMessage());
+ * }
+ * }
+ *
+ * Example 2: Using an explicit parent class loader
+ *
+ * {@code
+ * ClassLoader parent = Thread.currentThread().getContextClassLoader();
+ * PluginLoader loader = new PluginLoader<>(MyPluginContract.class, parent);
+ * }
+ *
+ * In most applications, callers only need {@link io.gdcc.spi.core.loader.PluginLoader} and
+ * {@link io.gdcc.spi.core.loader.LoaderConfiguration}. The remaining types in this package primarily support
+ * diagnostics, runtime result transport, and internal validation mechanics.
+ */
+package io.gdcc.spi.core.loader;
\ No newline at end of file
diff --git a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java
index a05ae6d..8f7ebef 100644
--- a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java
+++ b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java
@@ -61,9 +61,11 @@ public Path pluginClassesDirectory() {
public static final class Builder {
private final List coreSources = new ArrayList<>();
+ private final List coreProcessors = new ArrayList<>();
private final List pluginSources = new ArrayList<>();
private final List pluginProcessors = new ArrayList<>();
+ // TODO: inject this via system property, set by surefire configuration
private String release = "17";
private boolean packagePluginAsJar = false;
private String pluginJarName = "plugin-under-test.jar";
@@ -80,6 +82,11 @@ public Builder addCoreSource(String relativePath, String content) {
this.coreSources.add(TestJavaCompiler.SourceFile.of(relativePath, content));
return this;
}
+
+ public Builder addCoreProcessor(Processor processor) {
+ this.coreProcessors.add(processor);
+ return this;
+ }
public Builder addPluginSource(String relativePath, String content) {
this.pluginSources.add(TestJavaCompiler.SourceFile.of(relativePath, content));
@@ -104,30 +111,26 @@ public Builder withPluginJarName(String pluginJarName) {
public LoaderTestEnvironment build() throws IOException {
TestCompilation coreCompilation = TestJavaCompiler.builder()
.withRelease(release)
+ .withProcessors(coreProcessors)
.build()
.compile(coreSources);
-
+
coreCompilation.assertSuccess();
- URLClassLoader coreClassLoader =
- coreCompilation.newClassLoader(Thread.currentThread().getContextClassLoader());
-
- TestJavaCompiler.Builder pluginCompilerBuilder = TestJavaCompiler.builder()
+ URLClassLoader coreClassLoader = coreCompilation.newClassLoader(Thread.currentThread().getContextClassLoader());
+
+ TestCompilation pluginCompilation = TestJavaCompiler.builder()
.withRelease(release)
- .withClasspathEntry(coreCompilation.classOutputDir());
-
- if (!pluginProcessors.isEmpty()) {
- pluginCompilerBuilder.withProcessors(pluginProcessors);
- }
-
- TestCompilation pluginCompilation = pluginCompilerBuilder
+ .withClasspathEntry(coreCompilation.classOutputDir())
+ .withProcessors(pluginProcessors)
.build()
.compile(pluginSources);
-
+
pluginCompilation.assertSuccess();
Path pluginArtifact = packagePluginAsJar
- ? pluginCompilation.createJar(pluginJarName)
+ // Use the parent = containing dir here, as the loader always scans full directories
+ ? pluginCompilation.createJar(pluginJarName).getParent()
: pluginCompilation.classOutputDir();
return new LoaderTestEnvironment(
diff --git a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java
index 1efc568..c75dc97 100644
--- a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java
+++ b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java
@@ -6,12 +6,15 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
import java.nio.file.Path;
+import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
class PluginLoaderIntegrationTest {
@@ -52,7 +55,7 @@ public String identity() {
final String simplePluginClassFile = pluginPackage.replace(".", "/") + "/" + simplePluginClass + ".java";
final String simplePluginCode = pluginCodeTemplate.formatted(pluginPackage, contractPackage, contractClass, simplePluginClass, contractClass);
- @ParameterizedTest
+ @ParameterizedTest(name = "API levels: core={0}, plugin={1}")
@CsvSource({"1,2","2,1"})
void rejectsPluginCompiledAgainstDifferentBaseApiLevel(int coreLevel, int pluginLevel) throws Exception {
// Given
@@ -86,8 +89,9 @@ void rejectsPluginCompiledAgainstDifferentBaseApiLevel(int coreLevel, int plugin
assertInstanceOf(LoaderProblem.PluginClassApiLevelMismatch.class, ex.getProblems().get(0));
}
- @Test
- void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception {
+ @ParameterizedTest(name = "Packaging as JAR: {0}")
+ @ValueSource(booleans = {true, false})
+ void acceptsPluginCompiledAgainstSameBaseApiLevel(boolean packageAsJar) throws Exception {
// Given
int apiLevel = 5;
@@ -105,7 +109,7 @@ void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception {
simplePluginCode
)
.addPluginProcessor(new PluginContractProcessor())
- .packagePluginAsJar(false)
+ .packagePluginAsJar(packageAsJar)
.build();
Class> pluginContractClass = env.coreClassLoader().loadClass(contractPackage + "." + contractClass);
@@ -115,11 +119,15 @@ void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception {
PluginLoader> loader = new PluginLoader<>(typedContract, env.coreClassLoader());
Path pluginLocation = Path.of(env.pluginArtifact().toString());
- // When
- var plugins = loader.load(pluginLocation);
-
- // Then
- assertEquals(1, plugins.size());
- assertEquals(pluginPackage + "." + simplePluginClass, plugins.get(0).plugin().getClass().getName());
+ try {
+ // When
+ var plugins = loader.load(pluginLocation);
+
+ // Then
+ assertEquals(1, plugins.size());
+ assertEquals(pluginPackage + "." + simplePluginClass, plugins.get(0).plugin().getClass().getName());
+ } catch (LoaderException e) {
+ fail("Loader problems detected:\n" + e.getProblems().stream().map(LoaderProblem::message).collect(Collectors.joining(",\n")), e);
+ }
}
}
diff --git a/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java
index bac2b22..ea35563 100644
--- a/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java
+++ b/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java
@@ -1,33 +1,56 @@
/**
- * Annotations used to declare Dataverse plugin contracts, plugin implementations,
+ * Annotations used to declare Dataverse plugin implementations, plugin contracts,
* and required core providers.
*
- * This package defines the author-facing SPI model:
- *
- * - a {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#BASE base contract}
- * is the unique, directly loadable identity of a plugin,
- * - a {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#CAPABILITY capability contract}
- * adds optional functionality but is never loaded directly,
- * - a {@linkplain io.gdcc.spi.meta.annotations.DataversePlugin plugin implementation}
- * must implement exactly one base contract and may additionally implement compatible capabilities,
- * - a {@linkplain io.gdcc.spi.meta.annotations.RequiredProvider required provider}
- * declares Dataverse infrastructure contracts needed by a plugin contract.
- *
- *
- * Only base contracts are used as plugin loading identities.
- *
- * Contract interfaces must extend {@link io.gdcc.spi.meta.plugin.Plugin}, declare
- * {@link io.gdcc.spi.meta.annotations.PluginContract}, and provide a compile-time
- * {@code int API_LEVEL} constant. Plugin contracts must not extend other plugin contracts
- * (with the single exception of a capability extending a required base contract).
- *
- * Capabilities are attached to a plugin through normal Java interface implementation.
- * This allows SPI authors to provide additional methods and default implementations
- * without introducing ambiguity into plugin loading. If multiple implemented interfaces
- * contribute conflicting default methods, the plugin implementation class must resolve
- * that conflict explicitly.
- *
- * Example with extending base contract:
+ * For plugin implementors
+ *
+ * The primary entry point in this package is
+ * {@link io.gdcc.spi.meta.annotations.DataversePlugin @DataversePlugin}.
+ * Plugin implementation classes intended for discovery and loading by Dataverse
+ * should declare this annotation.
+ *
+ *
A plugin implementation must implement exactly one
+ * {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#BASE base contract}
+ * and may additionally implement compatible
+ * {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#CAPABILITY capability contracts}.
+ * Only the base contract serves as the direct loading identity of the plugin.
+ *
+ *
Example: Plugin side
+ * (The implemented interface are from the example below)
+ *
+ *
{@code
+ * * @DataversePlugin
+ * * public class Grill implements FooBar, BarBeque {
+ * * // no override needed unless another default conflicts
+ * * }
+ * * }
+ *
+ * For SPI authors and maintainers
+ *
+ * SPI contracts are declared with
+ * {@link io.gdcc.spi.meta.annotations.PluginContract @PluginContract}.
+ * A base contract defines the unique, directly loadable plugin kind. A capability
+ * contract defines additional optional behavior and is never loaded directly.
+ *
+ * Contract interfaces must extend {@link io.gdcc.spi.meta.plugin.Plugin},
+ * declare {@link io.gdcc.spi.meta.annotations.PluginContract}, and provide a
+ * compile-time {@code int API_LEVEL} constant. Plugin contracts must not extend
+ * other plugin contracts, except that a capability may extend its required base
+ * contract.
+ *
+ * Required Dataverse infrastructure dependencies are declared with
+ * {@link io.gdcc.spi.meta.annotations.RequiredProvider @RequiredProvider},
+ * which identifies core provider contracts needed by a plugin contract.
+ *
+ * Capabilities are attached to a plugin through normal Java interface
+ * implementation. This allows SPI authors to define additional methods and
+ * default implementations without introducing ambiguity into plugin loading.
+ * If multiple implemented interfaces contribute conflicting default methods,
+ * the plugin implementation class must resolve that conflict explicitly.
+ *
+ * Example: Contract side
+ * A capability extending its required base contract:
+ *
*
{@code
* @PluginContract(role = PluginContract.Role.BASE)
* public interface FooBar extends Plugin {
@@ -46,11 +69,6 @@
* return "application/bbq";
* }
* }
- *
- * @DataversePlugin
- * public class Grill implements FooBar, BarBeque {
- * // no override needed unless another default conflicts
- * }
* }
*/
package io.gdcc.spi.meta.annotations;
diff --git a/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java
new file mode 100644
index 0000000..07b33f9
--- /dev/null
+++ b/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java
@@ -0,0 +1,24 @@
+/**
+ * Provides descriptor models and utilities for plugin metadata discovery, serialization, and runtime interpretation.
+ *
+ * This package contains:
+ *
+ * - immutable descriptor types representing plugin metadata,
+ * - format utilities for reading and writing descriptor resources, and
+ * - scanning support for locating descriptor definitions in directories and JAR files.
+ *
+ *
+ * The package separates metadata concerns into distinct layers:
+ *
+ * - raw descriptor data represented in a serialized or transport-friendly form,
+ * - source-aware descriptor views that retain origin information, and
+ * - runtime-facing descriptors that use resolved Java types.
+ *
+ *
+ * All descriptor value types are designed to be immutable and safe to share.
+ * Utility classes in this package provide stateless helper methods for working
+ * with descriptor files and service metadata.
+ *
+ *
Unless otherwise noted, {@code null} values are not permitted.
+ */
+package io.gdcc.spi.meta.descriptor;
\ No newline at end of file
diff --git a/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java
new file mode 100644
index 0000000..2b78e0a
--- /dev/null
+++ b/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java
@@ -0,0 +1,18 @@
+/**
+ * Defines the core SPI contracts for plugins and core-provided services.
+ *
+ *
This package contains the foundational marker and contract interfaces used by the Dataverse Plugin System:
+ *
+ * - {@link io.gdcc.spi.meta.plugin.Plugin}, the supertype base contract for any Dataverse Plugin contracts, and
+ * - {@link io.gdcc.spi.meta.plugin.CoreProvider}, the supertype base contract for framework-provided services that plugins may depend on.
+ *
+ *
+ * These types form the most basic public interaction layer between the Dataverse core and community contributed
+ * plugin implementations. Plugin contracts are intended to be stable, minimal, and easy to implement.
+ *
+ *
Contracts and plugin implementations are expected to provide clear, machine-readable identities and to participate in the wider
+ * plugin runtime through metadata and discovery mechanisms, defined by annotations from {@link io.gdcc.spi.meta.annotations}.
+ *
+ *
Unless otherwise noted, {@code null} values are not permitted.
+ */
+package io.gdcc.spi.meta.plugin;
\ No newline at end of file
diff --git a/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java
new file mode 100644
index 0000000..dcb295f
--- /dev/null
+++ b/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java
@@ -0,0 +1,57 @@
+/**
+ * Provides annotation-processing support for generation of Dataverse Plugin Metadata.
+ *
+ *
This package contains compile-time infrastructure for validating plugin declarations and producing metadata
+ * resources consumed by the runtime plugin loading mechanism.
+ *
+ *
Overview
+ *
+ * The processor in this package connects the declaration model in {@link io.gdcc.spi.meta.annotations},
+ * the core SPI contracts in {@link io.gdcc.spi.meta.plugin}, and the descriptor model in {@link io.gdcc.spi.meta.descriptor}.
+ *
+ *
It evaluates
+ * {@link io.gdcc.spi.meta.annotations.DataversePlugin @DataversePlugin},
+ * {@link io.gdcc.spi.meta.annotations.PluginContract @PluginContract}, and
+ * {@link io.gdcc.spi.meta.annotations.RequiredProvider @RequiredProvider}
+ * declarations on types derived from {@link io.gdcc.spi.meta.plugin.Plugin} and {@link io.gdcc.spi.meta.plugin.CoreProvider},
+ * validates their structure, and generates serialized {@link io.gdcc.spi.meta.descriptor.Descriptor descriptor}
+ * resources in the format defined by {@link io.gdcc.spi.meta.descriptor.DescriptorFormat}.
+ *
+ *
Generated metadata
+ *
+ * The primary generated output is descriptor metadata under {@value io.gdcc.spi.meta.descriptor.DescriptorFormat#DESCRIPTOR_DIRECTORY}.
+ * These descriptors are intended to serve as the authoritative source for plugin discovery and compatibility metadata,
+ * and may later be discovered by {@link io.gdcc.spi.meta.descriptor.DescriptorScanner} and resolved into runtime-facing
+ * models such as {@link io.gdcc.spi.meta.descriptor.PluginDescriptor} by the plugin loader.
+ *
+ *
For compatibility with older runtime environments, the processor may also emit {@code META-INF/services} resources.
+ * That mechanism is transitional: descriptor-based discovery is the preferred direction, as it avoids several class-loading
+ * and isolation limitations associated with {@link java.util.ServiceLoader ServiceLoader}-based loading alone.
+ *
+ *
Maven configuration
+ *
+ * On JDK 22 and newer, annotation processors should be configured explicitly in the build.
+ * In Maven, this is typically done through the {@code maven-compiler-plugin}, for example:
+ *
+ *
{@code
+ *
+ *
+ *
+ * maven-compiler-plugin
+ *
+ *
+ *
+ * io.gdcc
+ * dataverse-spi
+ * ${spi.version}
+ *
+ *
+ *
+ *
+ *
+ *
+ * }
+ *
+ * Types in this package are mainly internal build-time infrastructure rather than part of the public runtime API.
+ */
+package io.gdcc.spi.meta.processor;
\ No newline at end of file
diff --git a/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java b/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java
index 14f668f..fd9d6ff 100644
--- a/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java
+++ b/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java
@@ -3103,7 +3103,7 @@ public interface DuplicateAnnotatedPlugin extends Plugin {
));
assertFalse(result.success(), "Compilation should fail");
- assertDiagnosticContains(result, Diagnostic.Kind.ERROR, "PluginContract is not a repeatable annotation type");
+ assertDiagnosticContains(result, Diagnostic.Kind.ERROR, "PluginContract is not a repeatable annotation");
}
}