diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f727d2..c2c754e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#77](https://github.com/itsallcode/openfasttrace-gradle/issues/77) + - Add support for OpenFastTrace plugin dependencies + ## [3.4.0] - 2026-09-22 - Upgrade to OpenFastTrace [4.10.0](https://github.com/itsallcode/openfasttrace/releases/tag/4.10.0) diff --git a/README.md b/README.md index b0d40eb..73c67d6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,22 @@ You can configure the following properties: * `filteredArtifactTypes`: Use only the listed artifact types during tracing * `filterWantedStatuses`: Import only specification items that have a status contained in the list of statuses. Possible values: `draft`, `proposed`, `approved`, `rejected`. See the [OFT user guide](https://github.com/itsallcode/openfasttrace/blob/main/doc/user_guide/user_guide.md#filtering-by-status) for details. +### Using OpenFastTrace Plugins + +OpenFastTrace extension plugins can be added with the `pluginDependencies` property. The dependencies are added to the classpath used by requirement collection and tracing: + +```groovy +repositories { + mavenCentral() +} + +requirementTracing { + pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:1.0.0'] +} +``` + +These are OpenFastTrace extension plugins, not Gradle build plugins. Plugin JARs must provide the appropriate OpenFastTrace service descriptors and should not include a duplicate incompatible `openfasttrace-api` dependency. Plugin discovery remains additive to OpenFastTrace's built-in plugin directory. + ### Configuring the Short Tag Importer The short tag importer allows omitting artifact type and the covered artifact type. Optionally you can add a prefix to the item name, e.g. a common module name. diff --git a/docs/plugin-loading-refactor-plan.md b/docs/plugin-loading-refactor-plan.md new file mode 100644 index 0000000..6a72ace --- /dev/null +++ b/docs/plugin-loading-refactor-plan.md @@ -0,0 +1,102 @@ +# Plan: Simplify plugin loading by consuming the new OFT plugin API + +Status: planned +Upstream issue: [itsallcode/openfasttrace#610 — Allow adding plugins via configuration](https://github.com/itsallcode/openfasttrace/issues/610) +Related: [openfasttrace-gradle#77 — Add support for OpenFastTrace plugin dependencies](https://github.com/itsallcode/openfasttrace-gradle/issues/77) + +## Goal + +Delete the Gradle plugin's custom class-loader machinery and let OpenFastTrace (OFT) +load plugin JARs directly, once OFT exposes a public API for passing plugin JAR paths +(issue #610). This assumes the upstream feature is available in an upcoming OFT release. + +## Why this is possible + +The current complexity exists only because OFT's plugin loading is not programmable: + +- `ServiceLoaderFactory` is package-private and hardcodes `$HOME/.oft/plugins`. +- `InitializingServiceLoader.load(Class, C)` is the only public entry point and + accepts no plugin locations. +- `ClassPathServiceLoader.filterOtherClassLoader` rejects any service whose + `getClass().getClassLoader()` differs from the origin's class loader. + +Because the Gradle plugin cannot tell OFT about extra plugin JARs, it hijacks the +thread context class loader (TCCL) with a child-first loader. Due to the class-loader +identity filter above, it must additionally locate and copy OFT's *built-in* provider +JARs (importer/exporter/reporter factories) into that same child loader — otherwise +built-in reporting and importing silently disappear as soon as any plugin is configured. + +OFT already implements this correctly for `$HOME/.oft/plugins` via `ServiceOrigin` + +its own `ChildFirstClassLoader`. Issue #610 exposes that mechanism to library users. + +## Assumed upstream API (issue #610) + +```java +Oft oft = new OftRunner(List.of(pluginJar1, pluginJar2)); +``` + +Threaded internally through `ServiceFactory` → `ImporterFactoryLoader` / +`ExporterFactoryLoader` / `ReporterFactoryLoader` → `InitializingServiceLoader.load( +serviceType, context, pluginJars)` → `ServiceLoaderFactory`, which merges the paths as +an additional `ServiceOrigin.forJars(pluginJars)`. The existing no-arg `OftRunner()` +delegates with an empty list, so the change is backward compatible. + +> Confirm the exact signature and release version before implementing. If the final API +> differs (e.g. plugin paths on `ImportSettings`/`ReportSettings` instead of the +> `OftRunner` constructor), adjust step 3 accordingly. + +## Steps + +1. **Bump the OFT version.** In `build.gradle`, set `oftVersion` to the first release + that contains the #610 API. +2. **Delete the class-loader package.** Remove + `src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/`: + - `OftPluginClassLoader.java` + - `ChildFirstClassLoader.java` + - `ParentClassLoader.java` +3. **Wire plugin files into `OftRunner`.** + - `CollectTask.collectRequirements()`: replace + `OftPluginClassLoader.runWithPlugins(pluginFiles, this::collectWithPlugins)` with a + direct call to `collectWithPlugins()`, and construct the runner with plugin paths. + - `TraceTask.trace()`: same for `traceWithPlugins()`. + - Build the plugin path list from the resolved `pluginFiles` `ConfigurableFileCollection`, + e.g. `pluginFiles.getFiles().stream().map(File::toPath).toList()`. + - Remove the now-unused `OftPluginClassLoader` imports. +4. **Update documentation.** + - `README.md` "Using OpenFastTrace Plugins": drop the class-loader/TCCL caveats and + note that OFT handles plugin isolation. + - `CHANGELOG.md`: add an entry under `[Unreleased]` referencing #610 and #77. +5. **Verify.** + +## Files + +| File | Change | +| --- | --- | +| `build.gradle` | Bump `oftVersion` | +| `src/main/java/.../task/CollectTask.java` | Use `new OftRunner(pluginPaths)`, drop wrapper | +| `src/main/java/.../task/TraceTask.java` | Use `new OftRunner(pluginPaths)`, drop wrapper | +| `src/main/java/.../task/classloader/*` | Delete (3 files) | +| `README.md` | Update plugin section | +| `CHANGELOG.md` | Add Unreleased entry | + +## Verification + +1. `./gradlew :test --tests org.itsallcode.openfasttrace.gradle.OpenFastTracePluginTest` + — in particular `testTraceExampleProjectWithPluginDependency`. +2. Run the `plugin-config` example and confirm both the built-in `plain` report **and** + the asciidoc plugin import work. +3. `./gradlew clean build --warning-mode fail -PenableConfigurationCache=true -PjavaVersion=25` + (per repo memory: local JDK is Java 25; use `-PjavaVersion=25`). + +## Risks / notes + +- **Cross-repo dependency.** This plan is blocked until the OFT release containing #610 + is published. Do not merge the Gradle change before then. +- **API drift.** The assumed `OftRunner(List)` signature may change during upstream + review; re-check #610 before implementing. +- **Child-first semantics.** OFT's `ChildFirstClassLoader` is fully child-first (no + `api`/`core` parent-first exception, unlike the current Gradle loader). This is safe + because the plugin developer guide forbids bundling `openfasttrace-api`; confirm this + holds for the asciidoc plugin. +- **No fallback.** Per decision, the Gradle plugin will hard-require the new OFT version + rather than keeping the TCCL-based loader for older versions. diff --git a/example-projects/plugin-config/build.gradle b/example-projects/plugin-config/build.gradle new file mode 100644 index 0000000..86e76b4 --- /dev/null +++ b/example-projects/plugin-config/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "base" + id 'org.itsallcode.openfasttrace' +} + +repositories { + mavenCentral() +} + +requirementTracing { + failBuild = true + inputDirectories = files('doc', 'src') + reportFormat = 'plain' + // Once we upgrade this, we can remove RegexMatchingImporterFactory + pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:1.0.0'] +} diff --git a/example-projects/plugin-config/doc/spec.adoc b/example-projects/plugin-config/doc/spec.adoc new file mode 100644 index 0000000..56e18b7 --- /dev/null +++ b/example-projects/plugin-config/doc/spec.adoc @@ -0,0 +1,6 @@ +== AsciiDoc Spec + +[.specitem, oft-sid="dsn~asciidoc-exampleB~1", oft-needs="impl,test"] +=== Example AsciiDoc Requirement + +Example AsciiDoc requirement diff --git a/example-projects/plugin-config/doc/spec.md b/example-projects/plugin-config/doc/spec.md new file mode 100644 index 0000000..f7393a1 --- /dev/null +++ b/example-projects/plugin-config/doc/spec.md @@ -0,0 +1,6 @@ +# MarkDown Tracing Example +`dsn~md-exampleA~1` + +Example MarkDown requirement + +Needs: impl, test diff --git a/example-projects/plugin-config/settings.gradle b/example-projects/plugin-config/settings.gradle new file mode 100644 index 0000000..782f259 --- /dev/null +++ b/example-projects/plugin-config/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'plugin-config' diff --git a/example-projects/plugin-config/src/Source.java b/example-projects/plugin-config/src/Source.java new file mode 100644 index 0000000..64c6abd --- /dev/null +++ b/example-projects/plugin-config/src/Source.java @@ -0,0 +1,5 @@ +// [impl->dsn~md-exampleA~1] +// [impl->dsn~asciidoc-exampleB~1] +class Source +{ +} diff --git a/example-projects/plugin-config/src/Test.java b/example-projects/plugin-config/src/Test.java new file mode 100644 index 0000000..f234e52 --- /dev/null +++ b/example-projects/plugin-config/src/Test.java @@ -0,0 +1,5 @@ +// [test->dsn~md-exampleA~1] +// [test->dsn~asciidoc-exampleB~1] +class Test +{ +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2..5097068 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ad7845b..9b7cb4a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef6..03754c4 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java index 9e20af8..da16676 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java @@ -71,6 +71,8 @@ private static TaskProvider createCollectTask(final Project rootPro task.setGroup(TASK_GROUP_NAME); task.setDescription("Collect requirements and generate specobject file"); task.getInputDirectories().set(getAllInputDirectories(rootProject.getAllprojects())); + task.getPluginFiles().from( + getPluginDependencies(rootProject, rootProject.getAllprojects())); task.getOutputFile().set( rootProject.getLayout().getBuildDirectory().file("reports/requirements.xml")); task.getPathConfig().set(getPathConfig(rootProject.getAllprojects())); @@ -109,6 +111,8 @@ private static void configureTracingTask(final Project rootProject, task.getReportFormat().set(config.getReportFormat()); task.getImportedRequirements() .from(getImportedRequirements(rootProject, rootProject.getAllprojects())); + task.getPluginFiles().from( + getPluginDependencies(rootProject, rootProject.getAllprojects())); task.getFilteredArtifactTypes().set(config.getFilteredArtifactTypes()); task.getFilteredTags().set(config.getFilteredTags()); task.getFilterAcceptsItemsWithoutTag().set(config.getFilterAcceptsItemsWithoutTag()); @@ -173,6 +177,45 @@ private static Optional> getImportedRequirements(final P return Optional.of(project.getConfigurations().named(CONFIG_NAME)); } + private static ConfigurableFileCollection getPluginDependencies(final Project rootProject, + final Set allProjects) + { + return rootProject.files(allProjects.stream() + .map(OpenFastTracePlugin::getPluginDependencies) + .flatMap(Optional::stream) + .toList()); + } + + private static Optional getPluginDependencies(final Project project) + { + final List dependencies = getConfig(project).getPluginDependencies().get(); + if (dependencies.isEmpty()) + { + return Optional.empty(); + } + final String CONFIG_NAME = "oftPluginConfig"; + return Optional.of(getOrCreateConfiguration(project, CONFIG_NAME, dependencies)); + } + + private static Configuration getOrCreateConfiguration(final Project project, + final String configurationName, final List dependencies) + { + final Configuration existingConfiguration = project.getConfigurations() + .findByName(configurationName); + if (existingConfiguration != null) + { + return existingConfiguration; + } + + final Configuration configuration = project.getConfigurations().create(configurationName); + dependencies.forEach(dependency -> { + LOG.info("Adding dependency {} with configuration {} to project {}", dependency, + configurationName, project); + project.getDependencies().add(configurationName, dependency); + }); + return configuration; + } + private static List getPathConfig(final Set allProjects) { return allProjects.stream() diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java b/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java index d5347de..ccacae0 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java @@ -26,6 +26,7 @@ public class TracingConfig private final ConfigurableFileCollection inputDirectories; private final RegularFileProperty reportFile; private final ListProperty importedRequirements; + private final ListProperty pluginDependencies; private final SetProperty filteredTags; private final SetProperty filteredArtifactTypes; private final SetProperty filterWantedStatuses; @@ -35,7 +36,7 @@ public class TracingConfig /** * Creates a tracing configuration with the plugin defaults. - * + * * @param project * the Gradle project owning the configuration */ @@ -49,6 +50,7 @@ public TracingConfig(final Project project) this.reportFormat = project.getObjects().property(String.class); this.reportFormat.set(DEFAULT_REPORT_FORMAT); this.importedRequirements = project.getObjects().listProperty(Object.class); + this.pluginDependencies = project.getObjects().listProperty(Object.class); this.filteredTags = project.getObjects().setProperty(String.class); this.filteredArtifactTypes = project.getObjects().setProperty(String.class); this.filterAcceptsItemsWithoutTag = project.getObjects().property(Boolean.class); @@ -62,7 +64,7 @@ public TracingConfig(final Project project) /** * Returns the report verbosity property. - * + * * @return the verbosity property */ public Property getReportVerbosity() @@ -121,6 +123,16 @@ public ListProperty getImportedRequirements() return importedRequirements; } + /** + * Returns the OpenFastTrace plugin dependencies. + * + * @return the plugin dependencies + */ + public ListProperty getPluginDependencies() + { + return pluginDependencies; + } + /** * Returns the tags to include in tracing. * @@ -301,6 +313,17 @@ public void setImportedRequirements(final List importedRequirements) this.importedRequirements.set(importedRequirements); } + /** + * Sets the OpenFastTrace plugin dependencies. + * + * @param pluginDependencies + * dependencies to add to the OpenFastTrace plugin classpath + */ + public void setPluginDependencies(final List pluginDependencies) + { + this.pluginDependencies.set(pluginDependencies); + } + /** * Sets the tags to include in tracing. * diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java index 963ab94..9057097 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java @@ -8,6 +8,7 @@ import java.util.stream.Stream; import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.SetProperty; @@ -17,6 +18,7 @@ import org.itsallcode.openfasttrace.api.importer.ImportSettings; import org.itsallcode.openfasttrace.api.importer.tag.config.PathConfig; import org.itsallcode.openfasttrace.core.*; +import org.itsallcode.openfasttrace.gradle.task.classloader.OftPluginClassLoader; import org.itsallcode.openfasttrace.gradle.task.config.SerializableTagPathConfig; /** Gradle task that collects specification items into a specobject file. */ @@ -35,6 +37,8 @@ public class CollectTask extends DefaultTask @SuppressWarnings({ "this-escape" }) public final ListProperty pathConfig = getProject().getObjects() .listProperty(SerializableTagPathConfig.class); + @SuppressWarnings("this-escape") + private final ConfigurableFileCollection pluginFiles = getProject().files(); /** Creates the task. */ public CollectTask() @@ -76,12 +80,28 @@ public ListProperty getPathConfig() return pathConfig; } + /** + * Returns the OpenFastTrace plugin files. + * + * @return the plugin files + */ + @InputFiles + @PathSensitive(PathSensitivity.ABSOLUTE) + public ConfigurableFileCollection getPluginFiles() + { + return pluginFiles; + } + /** Collects specification items and writes the specobject file. */ @TaskAction public void collectRequirements() { createReportOutputDir(); + OftPluginClassLoader.runWithPlugins(pluginFiles, this::collectWithPlugins); + } + private void collectWithPlugins() + { final Oft oft = new OftRunner(); final ImportSettings settings = getImportSettings(); getLogger().info("Importing from {} locations {} and {} path configurations: {}", diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java index 4821745..c9cd267 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java @@ -20,6 +20,7 @@ import org.itsallcode.openfasttrace.api.report.ReportVerbosity; import org.itsallcode.openfasttrace.core.Oft; import org.itsallcode.openfasttrace.core.OftRunner; +import org.itsallcode.openfasttrace.gradle.task.classloader.OftPluginClassLoader; /** Gradle task that traces requirements and writes a report. */ @SuppressWarnings("this-escape") @@ -38,6 +39,7 @@ public class TraceTask extends DefaultTask private final Property detailsSectionDisplay = getProject().getObjects() .property(DetailsSectionDisplay.class); private final ConfigurableFileCollection importedRequirements = getProject().files(); + private final ConfigurableFileCollection pluginFiles = getProject().files(); private final SetProperty filteredArtifactTypes = getProject().getObjects() .setProperty(String.class); private final SetProperty filteredTags = getProject().getObjects() @@ -123,6 +125,18 @@ public ConfigurableFileCollection getImportedRequirements() return importedRequirements; } + /** + * Returns the OpenFastTrace plugin files. + * + * @return the plugin files + */ + @InputFiles + @PathSensitive(PathSensitivity.ABSOLUTE) + public ConfigurableFileCollection getPluginFiles() + { + return pluginFiles; + } + /** * Returns the artifact type filter. * @@ -201,6 +215,11 @@ private boolean shouldFailBuild() public void trace() { createReportOutputDir(); + OftPluginClassLoader.runWithPlugins(pluginFiles, this::traceWithPlugins); + } + + private void traceWithPlugins() + { final Oft oft = new OftRunner(); final ImportSettings importSettings = getImportSettings(); final List importedItems = oft.importItems(importSettings); diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java new file mode 100644 index 0000000..68b71e6 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java @@ -0,0 +1,81 @@ +package org.itsallcode.openfasttrace.gradle.task.classloader; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This class loader will first try to load the class from the given URLs and + * then from the parent class loader, unlike {@link URLClassLoader} which does + * it the other way around. + *

+ * This allows us to prefer external plugins over plugins on the classpath + * included with OFT. + *

+ * This is based on + * + * "Java: A Child First Class Loader" by Isuru Weerarathna + * + *

+ */ +class ChildFirstClassLoader extends URLClassLoader +{ + private static final Logger LOGGER = Logger.getLogger(ChildFirstClassLoader.class.getName()); + + ChildFirstClassLoader(final String name, final URL[] urls, final ClassLoader parent) + { + super(name, urls, parent); + } + + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException + { + if (isSharedOpenFastTraceClass(name)) + { + return super.loadClass(name, resolve); + } + final Class loadedClass = findClass(name, resolve); + if (resolve) + { + resolveClass(loadedClass); + } + return loadedClass; + } + + private static boolean isSharedOpenFastTraceClass(final String name) + { + return name.startsWith("org.itsallcode.openfasttrace.api.") + || name.startsWith("org.itsallcode.openfasttrace.core."); + } + + private Class findClass(final String name, final boolean resolve) throws ClassNotFoundException + { + // Has the class loaded already? + final Class loadedClass = findLoadedClass(name); + if (loadedClass != null) + { + return loadedClass; + } + return loadClassInternally(name, resolve); + } + + private Class loadClassInternally(final String name, final boolean resolve) throws ClassNotFoundException + { + try + { + // Find the class from given jar urls + return findClass(name); + } + catch (final ClassNotFoundException ignore) + { + LOGGER.log(Level.FINEST, () -> "Unable to find class " + name + " with child classloader '" + + this.getClass().getClassLoader().getName() + "'. " + + "Falling back to parent classloader '" + super.getClass().getClassLoader().getName() + "'."); + // Class does not exist in the given URLs. + // Let's try finding it in our parent class's classloader. + // This will throw ClassNotFoundException on failure. + return super.loadClass(name, resolve); + } + } +} diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java new file mode 100644 index 0000000..a0cc872 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -0,0 +1,124 @@ +package org.itsallcode.openfasttrace.gradle.task.classloader; + +import java.io.IOException; +import java.net.*; +import java.util.*; + +import org.gradle.api.file.FileCollection; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.itsallcode.openfasttrace.core.OftRunner; + +/** Runs OpenFastTrace operations with additional plugin artifacts on the context classpath. */ +public final class OftPluginClassLoader +{ + private static final Logger LOG = Logging.getLogger(OftPluginClassLoader.class); + + private OftPluginClassLoader() + { + } + + /** + * Runs an operation with the given plugin files available to service loading. + * + * @param pluginFiles + * plugin artifacts to expose + * @param action + * operation to run + */ + public static void runWithPlugins(final FileCollection pluginFiles, final Runnable action) + { + if (pluginFiles.isEmpty()) + { + action.run(); + return; + } + + final Thread thread = Thread.currentThread(); + final ClassLoader originalClassLoader = thread.getContextClassLoader(); + final ClassLoader parent = new ParentClassLoader(OftRunner.class.getClassLoader(), originalClassLoader); + final URLClassLoader pluginClassLoader = createClassLoader(pluginFiles, parent); + thread.setContextClassLoader(pluginClassLoader); + try + { + action.run(); + } + finally + { + thread.setContextClassLoader(originalClassLoader); + try + { + pluginClassLoader.close(); + } + catch (final IOException e) + { + LOG.warn("Could not close OpenFastTrace plugin classloader", e); + } + } + } + + private static URLClassLoader createClassLoader(final FileCollection pluginFiles, final ClassLoader parent) + { + final Set pluginUrls = new HashSet<>(pluginFiles.getFiles().stream() + .map(file -> file.toPath().toUri()) + .toList()); + // OFT only accepts service providers loaded by the classloader that discovered them. + // Add OFT's built-in provider JARs to this loader so they are not filtered out. + addServiceProviderJars(parent, pluginUrls, + "org.itsallcode.openfasttrace.api.exporter.ExporterFactory"); + addServiceProviderJars(parent, pluginUrls, + "org.itsallcode.openfasttrace.api.importer.ImporterFactory"); + addServiceProviderJars(parent, pluginUrls, + "org.itsallcode.openfasttrace.api.reporter.ReporterFactory"); + final URL[] urls = pluginUrls.stream() + .map(OftPluginClassLoader::toUrl) + .toArray(URL[]::new); + final String pluginUrlsString = Arrays.toString(urls); + return new ChildFirstClassLoader("ChildFirst ClassLoader for " + pluginUrlsString, urls, parent); + } + + private static void addServiceProviderJars(final ClassLoader parent, final Set urls, + final String serviceName) + { + try + { + final String resourceName = "META-INF/services/" + serviceName; + for (final URL resource : java.util.Collections.list(parent.getResources(resourceName))) + { + final URLConnection connection = resource.openConnection(); + if (connection instanceof final JarURLConnection jarConnection) + { + urls.add(toUri(jarConnection.getJarFileURL())); + } + } + } + catch (final IOException e) + { + throw new IllegalStateException("Could not locate service provider jars for " + serviceName, e); + } + } + + private static URI toUri(final URL url) + { + try + { + return url.toURI(); + } + catch (final URISyntaxException e) + { + throw new IllegalStateException("Invalid JAR file URL: " + url, e); + } + } + + private static URL toUrl(final URI uri) + { + try + { + return uri.toURL(); + } + catch (final MalformedURLException e) + { + throw new IllegalStateException("Invalid plugin JAR URI: " + uri, e); + } + } +} diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java new file mode 100644 index 0000000..b7b50d4 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java @@ -0,0 +1,73 @@ +package org.itsallcode.openfasttrace.gradle.task.classloader; + +import java.io.IOException; +import java.net.URL; +import java.util.*; + +final class ParentClassLoader extends ClassLoader +{ + private final ClassLoader[] parents; + + ParentClassLoader(final ClassLoader... parents) + { + super(null); + this.parents = Arrays.stream(parents) + .filter(Objects::nonNull) + .distinct() + .toArray(ClassLoader[]::new); + } + + @Override + @SuppressWarnings("java:S3032") // Explicit loading is required to delegate to multiple independent parents. + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException + { + for (final ClassLoader parent : parents) + { + try + { + return Class.forName(name, resolve, parent); + } + catch (final ClassNotFoundException e) + { + // Ignore and try the next parent class loader‚ + } + } + throw new ClassNotFoundException(name); + } + + @Override + public URL getResource(final String name) + { + for (final ClassLoader parent : parents) + { + final URL resource = parent.getResource(name); + if (resource != null) + { + return resource; + } + } + return null; + } + + @Override + public Enumeration getResources(final String name) throws IOException + { + final List resources = Arrays.stream(parents) + .map(parent -> getResources(parent, name)) + .flatMap(List::stream) + .toList(); + return Collections.enumeration(resources); + } + + private static List getResources(final ClassLoader parent, final String name) + { + try + { + return Collections.list(parent.getResources(name)); + } + catch (final IOException e) + { + throw new IllegalStateException("Could not get resources for " + name, e); + } + } +} diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/GradleTestConfig.java b/src/test/java/org/itsallcode/openfasttrace/gradle/GradleTestConfig.java index a561d8f..7acd0ff 100644 --- a/src/test/java/org/itsallcode/openfasttrace/gradle/GradleTestConfig.java +++ b/src/test/java/org/itsallcode/openfasttrace/gradle/GradleTestConfig.java @@ -11,8 +11,7 @@ public enum GradleTestConfig * Version 8 does not support testkit with configuration cache, see * https://github.com/gradle/gradle/issues/25979 */ - // PREVIOUS_VERSION("8.14.5"), - CURRENT_VERSION("9.7.1"); + PREVIOUS_VERSION("9.7.1"), CURRENT_VERSION("9.8.0"); public final String gradleVersion; diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java index 27380c4..9482f0d 100644 --- a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java +++ b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java @@ -30,6 +30,7 @@ class OpenFastTracePluginTest private static final Path DEPENDENCY_CONFIG_DIR = EXAMPLES_DIR.resolve("dependency-config"); private static final Path PUBLISH_CONFIG_DIR = EXAMPLES_DIR.resolve("publish-config"); private static final Path HTML_REPORT_CONFIG_DIR = EXAMPLES_DIR.resolve("html-report"); + private static final Path PLUGIN_CONFIG_DIR = EXAMPLES_DIR.resolve("plugin-config"); @Parameter private GradleTestConfig config; @@ -318,6 +319,13 @@ void testTraceMultiProject() .assertReportFileLines("ok - 6 total"); } + @Test + void testTraceExampleProjectWithPluginDependency() + { + testConfigurationCache(PLUGIN_CONFIG_DIR, Path.of("build/reports/tracing.txt"), + "ok - 6 total"); + } + @Test void traceDependencyProject() { diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java b/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java index 1adf031..2e4d722 100644 --- a/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java +++ b/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.*; +import java.util.function.Function; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; @@ -53,15 +54,18 @@ public PluginTestFixture withReportFile(final Path relativeReportPath) Result run() { - final GradleRunner runner = createGradleRunner(); - final BuildResult buildResult = runner.build(); - return new Result(buildResult); + return run(GradleRunner::build); } Result runExpectingFailure() + { + return run(GradleRunner::buildAndFail); + } + + private Result run(final Function runnerFunction) { final GradleRunner runner = createGradleRunner(); - final BuildResult buildResult = runner.buildAndFail(); + final BuildResult buildResult = runnerFunction.apply(runner); return new Result(buildResult); }