Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 102 additions & 0 deletions docs/plugin-loading-refactor-plan.md
Original file line number Diff line number Diff line change
@@ -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<T>, 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<Path>)` 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.
16 changes: 16 additions & 0 deletions example-projects/plugin-config/build.gradle
Original file line number Diff line number Diff line change
@@ -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']
}
6 changes: 6 additions & 0 deletions example-projects/plugin-config/doc/spec.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
== AsciiDoc Spec

[.specitem, oft-sid="dsn~asciidoc-exampleB~1", oft-needs="impl,test"]
=== Example AsciiDoc Requirement

Example AsciiDoc requirement
6 changes: 6 additions & 0 deletions example-projects/plugin-config/doc/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# MarkDown Tracing Example
`dsn~md-exampleA~1`

Example MarkDown requirement

Needs: impl, test
1 change: 1 addition & 0 deletions example-projects/plugin-config/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = 'plugin-config'
5 changes: 5 additions & 0 deletions example-projects/plugin-config/src/Source.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// [impl->dsn~md-exampleA~1]
// [impl->dsn~asciidoc-exampleB~1]
class Source
{
}
5 changes: 5 additions & 0 deletions example-projects/plugin-config/src/Test.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// [test->dsn~md-exampleA~1]
// [test->dsn~asciidoc-exampleB~1]
class Test
{
}
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 41 additions & 11 deletions gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ private static TaskProvider<CollectTask> 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()));
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -173,6 +177,45 @@ private static Optional<Provider<Configuration>> getImportedRequirements(final P
return Optional.of(project.getConfigurations().named(CONFIG_NAME));
}

private static ConfigurableFileCollection getPluginDependencies(final Project rootProject,
final Set<Project> allProjects)
{
return rootProject.files(allProjects.stream()
.map(OpenFastTracePlugin::getPluginDependencies)
.flatMap(Optional::stream)
.toList());
}

private static Optional<Configuration> getPluginDependencies(final Project project)
{
final List<Object> 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<Object> 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<SerializableTagPathConfig> getPathConfig(final Set<Project> allProjects)
{
return allProjects.stream()
Expand Down
Loading
Loading