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 c5c3184..7a08c4d 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..06799d8 --- /dev/null +++ b/example-projects/plugin-config/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "base" + id 'org.itsallcode.openfasttrace' +} + +repositories { + mavenCentral() +} + +requirementTracing { + failBuild = true + inputDirectories = files('doc', 'src') + reportFormat = 'plain' + 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 9fa7f28..5097068 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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..dd1a7bb 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; @@ -35,6 +36,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 +79,23 @@ 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(); - 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..68c5303 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java @@ -38,6 +38,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 +124,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. * 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 1a8bc52..26f78c5 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,13 +54,18 @@ public PluginTestFixture withReportFile(final Path relativeReportPath) Result run() { - final BuildResult buildResult = createGradleRunner().build(); - return new Result(buildResult); + return run(GradleRunner::build); } Result runExpectingFailure() { - final BuildResult buildResult = createGradleRunner().buildAndFail(); + return run(GradleRunner::buildAndFail); + } + + private Result run(final Function runnerFunction) + { + final GradleRunner runner = createGradleRunner(); + final BuildResult buildResult = runnerFunction.apply(runner); return new Result(buildResult); }