From 4340ea358013eec3f33f300a47ab724b1150ebd6 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 21 Aug 2026 14:25:09 +0200 Subject: [PATCH 01/17] Allow adding OFT plugins --- CHANGELOG.md | 2 + README.md | 16 + example-projects/plugin-config/build.gradle | 15 + example-projects/plugin-config/doc/spec.adoc | 6 + example-projects/plugin-config/doc/spec.md | 6 + .../plugin-config/settings.gradle | 1 + .../plugin-config/src/Source.java | 5 + example-projects/plugin-config/src/Test.java | 5 + .../gradle/OpenFastTracePlugin.java | 41 +- .../gradle/config/TracingConfig.java | 27 +- .../gradle/task/CollectTask.java | 36 +- .../gradle/task/OftPluginClassLoader.java | 125 +++ .../openfasttrace/gradle/task/TraceTask.java | 61 +- .../gradle/OpenFastTracePluginTest.java | 744 +++++++++--------- 14 files changed, 683 insertions(+), 407 deletions(-) create mode 100644 example-projects/plugin-config/build.gradle create mode 100644 example-projects/plugin-config/doc/spec.adoc create mode 100644 example-projects/plugin-config/doc/spec.md create mode 100644 example-projects/plugin-config/settings.gradle create mode 100644 example-projects/plugin-config/src/Source.java create mode 100644 example-projects/plugin-config/src/Test.java create mode 100644 src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java diff --git a/CHANGELOG.md b/CHANGELOG.md index eadae15..45ef9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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 - [PR #80](https://github.com/itsallcode/openfasttrace-gradle/pull/80) - Fix JavaDoc warnings and let build fail on warnings - [PR #82](https://github.com/itsallcode/openfasttrace-gradle/pull/82) diff --git a/README.md b/README.md index 3f5c92e..c0bb4d6 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,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:0.3.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/example-projects/plugin-config/build.gradle b/example-projects/plugin-config/build.gradle new file mode 100644 index 0000000..132496f --- /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:0.3.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/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java index 44a07a7..5f93558 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java @@ -70,6 +70,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())); @@ -107,6 +109,8 @@ private static void configureTask(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()); @@ -155,11 +159,40 @@ private static ConfigurableFileCollection getImportedRequirements(final Project private static Configuration getImportedRequirements(final Project project) { final String CONFIG_NAME = "oftRequirementConfig"; - final Configuration configuration = project.getConfigurations().create(CONFIG_NAME); - getConfig(project).getImportedRequirements().get().forEach(dependency -> { + return getOrCreateConfiguration(project, CONFIG_NAME, + getConfig(project).getImportedRequirements().get()); + } + + private static ConfigurableFileCollection getPluginDependencies(final Project rootProject, + final Set allProjects) + { + return rootProject.files(allProjects.stream() // + .map(OpenFastTracePlugin::getPluginDependencies) // + .toList()); + } + + private static Configuration getPluginDependencies(final Project project) + { + final String CONFIG_NAME = "oftPluginConfig"; + return getOrCreateConfiguration(project, CONFIG_NAME, + getConfig(project).getPluginDependencies().get()); + } + + 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, - CONFIG_NAME, project); - project.getDependencies().add(CONFIG_NAME, dependency); + configurationName, project); + project.getDependencies().add(configurationName, dependency); }); return configuration; } 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 93cd06d..423a3b6 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/config/TracingConfig.java @@ -22,6 +22,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; @@ -31,7 +32,7 @@ public class TracingConfig /** * Creates a tracing configuration with the plugin defaults. - * + * * @param project * the Gradle project owning the configuration */ @@ -44,6 +45,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); @@ -57,7 +59,7 @@ public TracingConfig(final Project project) /** * Returns the report verbosity property. - * + * * @return the verbosity property */ public Property getReportVerbosity() @@ -105,6 +107,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. * @@ -221,6 +233,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..d1df9a7 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,21 +79,34 @@ 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: {}", - settings.getInputs().size(), settings.getInputs(), settings.getPathConfigs().size(), - settings.getPathConfigs()); - final List importedItems = oft.importItems(settings); - final Path output = getOuputFileInternal().toPath(); - getLogger().info("Imported {} spec items, writing to {}", importedItems.size(), output); - oft.exportToPath(importedItems, output, getExportSettings()); + OftPluginClassLoader.runWithPlugins(pluginFiles, () -> { + final Oft oft = new OftRunner(); + final ImportSettings settings = getImportSettings(); + getLogger().info("Importing from {} locations {} and {} path configurations: {}", + settings.getInputs().size(), settings.getInputs(), settings.getPathConfigs().size(), + settings.getPathConfigs()); + final List importedItems = oft.importItems(settings); + final Path output = getOuputFileInternal().toPath(); + getLogger().info("Imported {} spec items, writing to {}", importedItems.size(), output); + oft.exportToPath(importedItems, output, getExportSettings()); + }); } private static ExportSettings getExportSettings() diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java new file mode 100644 index 0000000..9a2e157 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java @@ -0,0 +1,125 @@ +package org.itsallcode.openfasttrace.gradle.task; + +import java.io.File; +import java.net.*; +import java.util.Arrays; + +import org.gradle.api.file.FileCollection; +import org.itsallcode.openfasttrace.core.OftRunner; + +/** Runs OpenFastTrace operations with additional plugin artifacts on the context classpath. */ +public final class OftPluginClassLoader +{ + private OftPluginClassLoader() + { + super(); + } + + /** + * 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 URLClassLoader pluginClassLoader = createClassLoader(pluginFiles, + new ParentClassLoader(originalClassLoader, OftRunner.class.getClassLoader())); + thread.setContextClassLoader(pluginClassLoader); + try + { + action.run(); + } + finally + { + thread.setContextClassLoader(originalClassLoader); + try + { + pluginClassLoader.close(); + } + catch (final java.io.IOException e) + { + throw new IllegalStateException("Could not close OpenFastTrace plugin classloader", + e); + } + } + } + + private static URLClassLoader createClassLoader(final FileCollection pluginFiles, + final ClassLoader parent) + { + final URL[] pluginUrls = pluginFiles.getFiles().stream().map(File::toURI) + .map(uri -> { + try + { + return uri.toURL(); + } + catch (final MalformedURLException e) + { + throw new IllegalArgumentException("Invalid plugin file URL: " + uri, + e); + } + }).toArray(URL[]::new); + return new ChildFirstClassLoader(pluginUrls, parent); + } + + private static final class ChildFirstClassLoader extends URLClassLoader + { + private ChildFirstClassLoader(final URL[] urls, final ClassLoader parent) + { + super(urls, parent); + } + + @Override + protected Class loadClass(final String name, final boolean resolve) + throws ClassNotFoundException + { + try + { + return findClass(name); + } + catch (final ClassNotFoundException e) + { + return super.loadClass(name, resolve); + } + } + } + + private static final class ParentClassLoader extends ClassLoader + { + private final ClassLoader[] parents; + + private ParentClassLoader(final ClassLoader... parents) + { + super(null); + this.parents = Arrays.stream(parents).filter(parent -> parent != null).distinct() + .toArray(ClassLoader[]::new); + } + + @Override + 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) + {} + } + throw new ClassNotFoundException(name); + } + } +} 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 a68e798..9eafc5e 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/TraceTask.java @@ -33,6 +33,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() @@ -106,6 +107,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. * @@ -184,31 +197,33 @@ private boolean shouldFailBuild() public void trace() { createReportOutputDir(); - final Oft oft = new OftRunner(); - final ImportSettings importSettings = getImportSettings(); - final List importedItems = oft.importItems(importSettings); - getLogger().info("Read {} spec items from {}", importedItems.size(), - importSettings.getInputs()); - final List linkedItems = oft.link(importedItems); - final Trace trace = oft.trace(linkedItems); - final Path reportPath = getOutputFileInternal().toPath(); - getLogger().info("Tracing result: {} total items, {} defects. Writing report to {}", - trace.count(), trace.countDefects(), reportPath); - oft.reportToPath(trace, reportPath, getReportSettings()); - if (trace.countDefects() > 0) - { - final String message = "Requirement tracing found " + trace.countDefects() - + " defects. See report at " + reportPath + " for details."; - if (shouldFailBuild()) + OftPluginClassLoader.runWithPlugins(pluginFiles, () -> { + final Oft oft = new OftRunner(); + final ImportSettings importSettings = getImportSettings(); + final List importedItems = oft.importItems(importSettings); + getLogger().info("Read {} spec items from {}", importedItems.size(), + importSettings.getInputs()); + final List linkedItems = oft.link(importedItems); + final Trace trace = oft.trace(linkedItems); + final Path reportPath = getOutputFileInternal().toPath(); + getLogger().info("Tracing result: {} total items, {} defects. Writing report to {}", + trace.count(), trace.countDefects(), reportPath); + oft.reportToPath(trace, reportPath, getReportSettings()); + if (trace.countDefects() > 0) { - throw new IllegalStateException(message); + final String message = "Requirement tracing found " + trace.countDefects() + + " defects. See report at " + reportPath + " for details."; + if (shouldFailBuild()) + { + throw new IllegalStateException(message); + } + getLogger().warn(message); } - getLogger().warn(message); - } - else - { - getLogger().info("Requirement tracing completed successfully."); - } + else + { + getLogger().info("Requirement tracing completed successfully."); + } + }); } private ReportSettings getReportSettings() diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java index 536e53d..961a624 100644 --- a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java +++ b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java @@ -22,387 +22,395 @@ @EnumSource(GradleTestConfig.class) class OpenFastTracePluginTest { - private static final Path EXAMPLES_DIR = Paths.get("example-projects").toAbsolutePath(); - private static final Path PROJECT_DEFAULT_CONFIG_DIR = EXAMPLES_DIR - .resolve("default-config"); - private static final Path PROJECT_CUSTOM_CONFIG_DIR = EXAMPLES_DIR.resolve("custom-config"); - private static final Path MULTI_PROJECT_DIR = EXAMPLES_DIR.resolve("multi-project"); - 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"); - - @Parameter - private GradleTestConfig config; - - @Test - void tracingTaskAddedToProject() - { - fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("tasks").run() - .assertOutput(containsString( - "traceRequirements - Trace requirements and generate tracing report")); - } - - @Test - void pluginUsesConfigurationCache() - { - testConfigurationCache(PROJECT_CUSTOM_CONFIG_DIR, - Path.of("build/custom-report.txt"), - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void pluginUsesConfigurationCacheWithMultiModuleProject() - { - testConfigurationCache(MULTI_PROJECT_DIR, Path.of("build/custom-report.txt"), - "ok - 6 total"); - } - - @Test - void pluginUsesConfigurationCacheWithImportedRequirements() - { - final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); - fixture.withArgs("clean").run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))); - - final Path dependencyZip = DEPENDENCY_CONFIG_DIR - .resolve("build/repo/requirements-1.0.zip"); - createDependencyZip(dependencyZip); - - testConfigurationCache(fixture, - Path.of("build/reports/tracing.txt"), - "requirements-1.0.zip!spec.md:2", - "requirements-1.0.zip!source.java:1", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - private void testConfigurationCache(final Path projectDir, final Path reportFile, - final String... lines) - { - testConfigurationCache(fixture(projectDir), reportFile, lines); - } - - private void testConfigurationCache(final PluginTestFixture fixture, final Path reportFile, - final String... lines) - { - fixture.withArgs("traceRequirements") - .withReportFile(reportFile) - .run() - .assertTraceOutcomeSuccessFromCacheOrUpToDate() - .assertReportFileLines(lines); - - fixture.withArgs("traceRequirements") - .withReportFile(reportFile) - .run() - .assertTraceOutcomeSuccessFromCacheOrUpToDate() - .assertReportFileLines(lines) - .assertOutput(containsString("Reusing configuration cache.")); - } - - @Test - void testTraceExampleProjectWithDefaultConfig() - { - fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/reports/tracing.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("ok - 0 total"); - } - - @Test - void testCollectExampleProjectWithCustomConfig() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "collectRequirements") - .withReportFile(Path.of("build/reports/requirements.xml")) - .run().assertCollectOutcomeSuccessOrFromCache() - .assertReportFileLines( - "\n" + - "", - """ - - - exampleB\ - """, """ - - approved - 0 - """, - - """ - 1 - - - dsn:exampleB - 1 - - - """, - - """ - - - exampleB - Tracing Example - draft - 1 - """, - - """ - 2 - Example requirement - - utest - impl - - - """, - - " \n" + - ""); - } - - @Test - void testCollectIsUpToDateWhenAlreadyRunBefore() - { - final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR); - fixture.withArgs("clean", "collectRequirements").run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))) - .assertCollectOutcomeSuccessOrFromCache(); - - fixture.withArgs("collectRequirements").run() - .assertOutcome(":collectRequirements", TaskOutcome.UP_TO_DATE); - } - - @Test - void testHtmlReportConfig() - { - fixture(HTML_REPORT_CONFIG_DIR) - .withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/reports/tracing.html")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("", - "✗", - "
"); - } - - @Test - void testTraceTaskUpToDateWhenAlreadyRun() - { - final PluginTestFixture fixture = fixture(HTML_REPORT_CONFIG_DIR); - fixture.withArgs("clean", "traceRequirements").run() - .assertTraceOutcomeSuccessOrFromCache(); - fixture.withArgs("traceRequirements").run().assertOutcome(":traceRequirements", - TaskOutcome.UP_TO_DATE); - } - - @Test - void testTraceExampleProjectWithCustomConfig() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void testTraceExampleProjectWithCustomConfigFailBuild() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true") - .withReportFile(Path.of("build/custom-report.txt")) - .runExpectingFailure() - .assertOutcome(":traceRequirements", TaskOutcome.FAILED) - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredArtifactTypes() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true", - "-PfilteredArtifactTypes=dsn") - .run() - .assertTraceOutcomeSuccessOrFromCache(); - } - - @Test - void filteredWantedStatuses() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=draft,approved") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredWantedStatusesNoMatch() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=approved") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - // Generated ID depends on JVM - "not ok [ in: 0 / 0 | out: 0 / 1 ✘ ] impl~exampleB-", - "not ok - 1 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredWantedStatusesInvalidStatus() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=invalid") - .runExpectingFailure() - .assertOutput(containsString( - "Invalid status 'invalid'. Valid statuses are: APPROVED, PROPOSED, DRAFT, REJECTED")); - } - - @Test - void testTraceExampleProjectWithCustomConfigFailBuildErrorMessage() - { - final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true"); - final UnexpectedBuildFailure exception = assertThrows(UnexpectedBuildFailure.class, - fixture::run); - assertAll( - () -> assertEquals(TaskOutcome.FAILED, - exception.getBuildResult() - .task(":traceRequirements") - .getOutcome()), - () -> assertThat(exception.getMessage(), - startsWith("Unexpected build execution failure")), - () -> assertThat(exception.getMessage(), - containsString("Requirement tracing found 1 defects. See report at"))); - } - - @Test - void testTraceMultiProject() - { - fixture(MULTI_PROJECT_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("ok - 6 total"); - } - - @Test - void traceDependencyProject() - { - final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); - fixture.withArgs("clean") - .run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))); - - final Path dependencyZip = DEPENDENCY_CONFIG_DIR - .resolve("build/repo/requirements-1.0.zip"); - createDependencyZip(dependencyZip); - - fixture.withArgs("traceRequirements") - .withReportFile(Path.of("build/reports/tracing.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "requirements-1.0.zip!spec.md:2", - "requirements-1.0.zip!source.java:1", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void publishToMavenRepo() - { - fixture(PUBLISH_CONFIG_DIR).withArgs("clean", "publishToMavenLocal") - .run() - .assertOutcome(":publishToMavenLocal", TaskOutcome.SUCCESS); - - final Path archive = PUBLISH_CONFIG_DIR - .resolve("build/distributions/publish-config-1.0.zip"); - assertTrue(Files.exists(archive)); - try (ZipFile zip = ZipFile.builder().setFile(archive.toFile()).get()) + private static final Path EXAMPLES_DIR = Paths.get("example-projects").toAbsolutePath(); + private static final Path PROJECT_DEFAULT_CONFIG_DIR = EXAMPLES_DIR + .resolve("default-config"); + private static final Path PROJECT_CUSTOM_CONFIG_DIR = EXAMPLES_DIR.resolve("custom-config"); + private static final Path MULTI_PROJECT_DIR = EXAMPLES_DIR.resolve("multi-project"); + 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; + + @Test + void tracingTaskAddedToProject() { - final String entryContent = readEntry(zip, "requirements.xml"); - assertThat(entryContent, containsString(""" - - - """)); - assertThat(entryContent, containsString(""" - - - exampleB - Tracing Example - approved - 1\ - """)); - assertThat(entryContent, containsString(""" - 2 - Example requirement - - utest - impl - - - """)); + fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("tasks").run() + .assertOutput(containsString( + "traceRequirements - Trace requirements and generate tracing report")); } - catch (final IOException e) + + @Test + void pluginUsesConfigurationCache() + { + testConfigurationCache(PROJECT_CUSTOM_CONFIG_DIR, + Path.of("build/custom-report.txt"), + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void pluginUsesConfigurationCacheWithMultiModuleProject() + { + testConfigurationCache(MULTI_PROJECT_DIR, Path.of("build/custom-report.txt"), + "ok - 6 total"); + } + + @Test + void pluginUsesConfigurationCacheWithImportedRequirements() + { + final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); + fixture.withArgs("clean").run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))); + + final Path dependencyZip = DEPENDENCY_CONFIG_DIR + .resolve("build/repo/requirements-1.0.zip"); + createDependencyZip(dependencyZip); + + testConfigurationCache(fixture, + Path.of("build/reports/tracing.txt"), + "requirements-1.0.zip!spec.md:2", + "requirements-1.0.zip!source.java:1", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + private void testConfigurationCache(final Path projectDir, final Path reportFile, + final String... lines) + { + testConfigurationCache(fixture(projectDir), reportFile, lines); + } + + private void testConfigurationCache(final PluginTestFixture fixture, final Path reportFile, + final String... lines) + { + fixture.withArgs("traceRequirements") + .withReportFile(reportFile) + .run() + .assertTraceOutcomeSuccessFromCacheOrUpToDate() + .assertReportFileLines(lines); + + fixture.withArgs("traceRequirements") + .withReportFile(reportFile) + .run() + .assertTraceOutcomeSuccessFromCacheOrUpToDate() + .assertReportFileLines(lines) + .assertOutput(containsString("Reusing configuration cache.")); + } + + @Test + void testTraceExampleProjectWithDefaultConfig() + { + fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/reports/tracing.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("ok - 0 total"); + } + + @Test + void testTraceExampleProjectWithPluginDependency() { - throw new UncheckedIOException("Failed to read zip file " + archive, e); + testConfigurationCache(PLUGIN_CONFIG_DIR, Path.of("build/reports/tracing.txt"), + "ok - 6 total"); } - } - private static String readEntry(final ZipFile zip, final String entryName) - { - final ZipArchiveEntry reqirementsEntry = zip.getEntry(entryName); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(zip.getInputStream(reqirementsEntry)))) + @Test + void testCollectExampleProjectWithCustomConfig() { - return reader.lines().collect(joining("\n")); + fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "collectRequirements") + .withReportFile(Path.of("build/reports/requirements.xml")) + .run().assertCollectOutcomeSuccessOrFromCache() + .assertReportFileLines( + "\n" + + "", + """ + + + exampleB\ + """, """ + + approved + 0 + """, + + """ + 1 + + + dsn:exampleB + 1 + + + """, + + """ + + + exampleB + Tracing Example + draft + 1 + """, + + """ + 2 + Example requirement + + utest + impl + + + """, + + " \n" + + ""); } - catch (final IOException e) + + @Test + void testCollectIsUpToDateWhenAlreadyRunBefore() + { + final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR); + fixture.withArgs("clean", "collectRequirements").run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))) + .assertCollectOutcomeSuccessOrFromCache(); + + fixture.withArgs("collectRequirements").run() + .assertOutcome(":collectRequirements", TaskOutcome.UP_TO_DATE); + } + + @Test + void testHtmlReportConfig() { - throw new UncheckedIOException("Failed to read entry " + entryName, e); + fixture(HTML_REPORT_CONFIG_DIR) + .withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/reports/tracing.html")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("", + "✗", + "
"); } - } - private static void createDependencyZip(final Path dependencyZip) - { - TestUtil.createDirs(dependencyZip.getParent()); - try (ZipFileBuilder zipBuilder = ZipFileBuilder.create(dependencyZip)) + @Test + void testTraceTaskUpToDateWhenAlreadyRun() { - zipBuilder - .addEntry("source.java", - PROJECT_DEFAULT_CONFIG_DIR - .resolve("src/source.java")) // - .addEntry("spec.md", PROJECT_DEFAULT_CONFIG_DIR - .resolve("doc/spec.md")); + final PluginTestFixture fixture = fixture(HTML_REPORT_CONFIG_DIR); + fixture.withArgs("clean", "traceRequirements").run() + .assertTraceOutcomeSuccessOrFromCache(); + fixture.withArgs("traceRequirements").run().assertOutcome(":traceRequirements", + TaskOutcome.UP_TO_DATE); } - catch (final IOException e) + + @Test + void testTraceExampleProjectWithCustomConfig() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void testTraceExampleProjectWithCustomConfigFailBuild() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true") + .withReportFile(Path.of("build/custom-report.txt")) + .runExpectingFailure() + .assertOutcome(":traceRequirements", TaskOutcome.FAILED) + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredArtifactTypes() { - throw new UncheckedIOException( - "Failed to create dependency zip " + dependencyZip, e); + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true", + "-PfilteredArtifactTypes=dsn") + .run() + .assertTraceOutcomeSuccessOrFromCache(); } - } - private PluginTestFixture fixture(final Path projectDir) - { - return PluginTestFixture.create(config, projectDir); - } + @Test + void filteredWantedStatuses() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=draft,approved") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredWantedStatusesNoMatch() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=approved") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + // Generated ID depends on JVM + "not ok [ in: 0 / 0 | out: 0 / 1 ✘ ] impl~exampleB-", + "not ok - 1 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredWantedStatusesInvalidStatus() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=invalid") + .runExpectingFailure() + .assertOutput(containsString( + "Invalid status 'invalid'. Valid statuses are: APPROVED, PROPOSED, DRAFT, REJECTED")); + } + + @Test + void testTraceExampleProjectWithCustomConfigFailBuildErrorMessage() + { + final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true"); + final UnexpectedBuildFailure exception = assertThrows(UnexpectedBuildFailure.class, + fixture::run); + assertAll( + () -> assertEquals(TaskOutcome.FAILED, + exception.getBuildResult() + .task(":traceRequirements") + .getOutcome()), + () -> assertThat(exception.getMessage(), + startsWith("Unexpected build execution failure")), + () -> assertThat(exception.getMessage(), + containsString("Requirement tracing found 1 defects. See report at"))); + } + + @Test + void testTraceMultiProject() + { + fixture(MULTI_PROJECT_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("ok - 6 total"); + } + + @Test + void traceDependencyProject() + { + final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); + fixture.withArgs("clean") + .run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))); + + final Path dependencyZip = DEPENDENCY_CONFIG_DIR + .resolve("build/repo/requirements-1.0.zip"); + createDependencyZip(dependencyZip); + + fixture.withArgs("traceRequirements") + .withReportFile(Path.of("build/reports/tracing.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "requirements-1.0.zip!spec.md:2", + "requirements-1.0.zip!source.java:1", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void publishToMavenRepo() + { + fixture(PUBLISH_CONFIG_DIR).withArgs("clean", "publishToMavenLocal") + .run() + .assertOutcome(":publishToMavenLocal", TaskOutcome.SUCCESS); + + final Path archive = PUBLISH_CONFIG_DIR + .resolve("build/distributions/publish-config-1.0.zip"); + assertTrue(Files.exists(archive)); + try (ZipFile zip = ZipFile.builder().setFile(archive.toFile()).get()) + { + final String entryContent = readEntry(zip, "requirements.xml"); + assertThat(entryContent, containsString(""" + + + """)); + assertThat(entryContent, containsString(""" + + + exampleB + Tracing Example + approved + 1\ + """)); + assertThat(entryContent, containsString(""" + 2 + Example requirement + + utest + impl + + + """)); + } + catch (final IOException e) + { + throw new UncheckedIOException("Failed to read zip file " + archive, e); + } + } + + private static String readEntry(final ZipFile zip, final String entryName) + { + final ZipArchiveEntry reqirementsEntry = zip.getEntry(entryName); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(zip.getInputStream(reqirementsEntry)))) + { + return reader.lines().collect(joining("\n")); + } + catch (final IOException e) + { + throw new UncheckedIOException("Failed to read entry " + entryName, e); + } + } + + private static void createDependencyZip(final Path dependencyZip) + { + TestUtil.createDirs(dependencyZip.getParent()); + try (ZipFileBuilder zipBuilder = ZipFileBuilder.create(dependencyZip)) + { + zipBuilder + .addEntry("source.java", + PROJECT_DEFAULT_CONFIG_DIR + .resolve("src/source.java")) // + .addEntry("spec.md", PROJECT_DEFAULT_CONFIG_DIR + .resolve("doc/spec.md")); + } + catch (final IOException e) + { + throw new UncheckedIOException( + "Failed to create dependency zip " + dependencyZip, e); + } + } + + private PluginTestFixture fixture(final Path projectDir) + { + return PluginTestFixture.create(config, projectDir); + } } From fdefe14e4ade164b7f60f1852f3c6d8619252315 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Mon, 31 Aug 2026 14:37:04 +0200 Subject: [PATCH 02/17] Revert integration test --- .../gradle/OpenFastTracePluginTest.java | 759 +++++++++--------- 1 file changed, 383 insertions(+), 376 deletions(-) diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java index 961a624..27380c4 100644 --- a/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java +++ b/src/test/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePluginTest.java @@ -22,395 +22,402 @@ @EnumSource(GradleTestConfig.class) class OpenFastTracePluginTest { - private static final Path EXAMPLES_DIR = Paths.get("example-projects").toAbsolutePath(); - private static final Path PROJECT_DEFAULT_CONFIG_DIR = EXAMPLES_DIR - .resolve("default-config"); - private static final Path PROJECT_CUSTOM_CONFIG_DIR = EXAMPLES_DIR.resolve("custom-config"); - private static final Path MULTI_PROJECT_DIR = EXAMPLES_DIR.resolve("multi-project"); - 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; - - @Test - void tracingTaskAddedToProject() + private static final Path EXAMPLES_DIR = Paths.get("example-projects").toAbsolutePath(); + private static final Path PROJECT_DEFAULT_CONFIG_DIR = EXAMPLES_DIR + .resolve("default-config"); + private static final Path PROJECT_CUSTOM_CONFIG_DIR = EXAMPLES_DIR.resolve("custom-config"); + private static final Path MULTI_PROJECT_DIR = EXAMPLES_DIR.resolve("multi-project"); + 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"); + + @Parameter + private GradleTestConfig config; + + @Test + void tracingTaskAddedToProject() + { + fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("tasks").run() + .assertOutput(containsString( + "traceRequirements - Trace requirements and generate tracing report")); + } + + @Test + void pluginUsesConfigurationCache() + { + testConfigurationCache(PROJECT_CUSTOM_CONFIG_DIR, + Path.of("build/custom-report.txt"), + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void pluginUsesConfigurationCacheWithMultiModuleProject() + { + testConfigurationCache(MULTI_PROJECT_DIR, Path.of("build/custom-report.txt"), + "ok - 6 total"); + } + + @Test + void pluginUsesConfigurationCacheWithImportedRequirements() + { + final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); + fixture.withArgs("clean").run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))); + + final Path dependencyZip = DEPENDENCY_CONFIG_DIR + .resolve("build/repo/requirements-1.0.zip"); + createDependencyZip(dependencyZip); + + testConfigurationCache(fixture, + Path.of("build/reports/tracing.txt"), + "requirements-1.0.zip!spec.md:2", + "requirements-1.0.zip!source.java:1", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + private void testConfigurationCache(final Path projectDir, final Path reportFile, + final String... lines) + { + testConfigurationCache(fixture(projectDir), reportFile, lines); + } + + private void testConfigurationCache(final PluginTestFixture fixture, final Path reportFile, + final String... lines) + { + fixture.withArgs("traceRequirements") + .withReportFile(reportFile) + .run() + .assertTraceOutcomeSuccessFromCacheOrUpToDate() + .assertReportFileLines(lines); + + fixture.withArgs("traceRequirements") + .withReportFile(reportFile) + .run() + .assertTraceOutcomeSuccessFromCacheOrUpToDate() + .assertReportFileLines(lines) + .assertOutput(containsString("Reusing configuration cache.")); + } + + @Test + void testTraceExampleProjectWithDefaultConfig() + { + fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/reports/tracing.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("ok - 0 total"); + } + + @Test + void testCollectExampleProjectWithCustomConfig() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "collectRequirements") + .withReportFile(Path.of("build/reports/requirements.xml")) + .run().assertCollectOutcomeSuccessOrFromCache() + .assertReportFileLines( + "\n" + + "", + """ + + + exampleB\ + """, """ + + approved + 0 + """, + + """ + 1 + + + dsn:exampleB + 1 + + + """, + + """ + + + exampleB + Tracing Example + draft + 1 + """, + + """ + 2 + Example requirement + + utest + impl + + + """, + + " \n" + + ""); + } + + @Test + void testCollectIsUpToDateWhenAlreadyRunBefore() + { + final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR); + fixture.withArgs("clean", "collectRequirements").run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))) + .assertCollectOutcomeSuccessOrFromCache(); + + fixture.withArgs("collectRequirements").run() + .assertOutcome(":collectRequirements", TaskOutcome.UP_TO_DATE); + } + + @Test + void testHtmlReportConfig() + { + fixture(HTML_REPORT_CONFIG_DIR) + .withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/reports/tracing.html")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("", + "✗", + "
"); + } + + @Test + void testTraceTaskUpToDateWhenAlreadyRun() + { + final PluginTestFixture fixture = fixture(HTML_REPORT_CONFIG_DIR); + fixture.withArgs("clean", "traceRequirements").run() + .assertTraceOutcomeSuccessOrFromCache(); + fixture.withArgs("traceRequirements").run().assertOutcome(":traceRequirements", + TaskOutcome.UP_TO_DATE); + } + + @Test + void testTraceExampleProjectWithCustomConfig() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void testTraceExampleProjectWithCustomConfigFailBuild() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true") + .withReportFile(Path.of("build/custom-report.txt")) + .runExpectingFailure() + .assertOutcome(":traceRequirements", TaskOutcome.FAILED) + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredArtifactTypes() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true", + "-PfilteredArtifactTypes=dsn") + .run() + .assertTraceOutcomeSuccessOrFromCache(); + } + + @Test + void filteredWantedStatuses() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=draft,approved") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredWantedStatusesNoMatch() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=approved") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + // Generated ID depends on JVM + "not ok [ in: 0 / 0 | out: 0 / 1 ✘ ] impl~exampleB-", + "not ok - 1 total, 1 direct, 0 transitive defects"); + } + + @Test + void filteredWantedStatusesInvalidStatus() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", + "-PfilterWantedStatuses=invalid") + .runExpectingFailure() + .assertOutput(containsString( + "Invalid status 'invalid'. Valid statuses are: APPROVED, PROPOSED, DRAFT, REJECTED")); + } + + @Test + void reportColorSchemeColor() + { + fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withoutBuildCache() + .withArgs("clean", "traceRequirements", "-PreportColorScheme=color") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccess() + .assertOutput(containsString( + "Report settings: verbosity=ALL, format=plain, detailsSectionDisplay=COLLAPSE, colorScheme=COLOR")) + .assertReportFileLines( + "\u001B[91mnot ok\u001B[0m - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void testTraceExampleProjectWithCustomConfigFailBuildErrorMessage() + { + final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR) + .withArgs("clean", "traceRequirements", "-PfailBuild=true"); + final UnexpectedBuildFailure exception = assertThrows(UnexpectedBuildFailure.class, + fixture::run); + assertAll( + () -> assertEquals(TaskOutcome.FAILED, + exception.getBuildResult() + .task(":traceRequirements") + .getOutcome()), + () -> assertThat(exception.getMessage(), + startsWith("Unexpected build execution failure")), + () -> assertThat(exception.getMessage(), + containsString("Requirement tracing found 1 defects. See report at"))); + } + + @Test + void testTraceMultiProject() + { + fixture(MULTI_PROJECT_DIR).withArgs("clean", "traceRequirements") + .withReportFile(Path.of("build/custom-report.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines("ok - 6 total"); + } + + @Test + void traceDependencyProject() + { + final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); + fixture.withArgs("clean") + .run() + .assertOutcome(":clean", + either(is(TaskOutcome.SUCCESS)) + .or(is(TaskOutcome.UP_TO_DATE))); + + final Path dependencyZip = DEPENDENCY_CONFIG_DIR + .resolve("build/repo/requirements-1.0.zip"); + createDependencyZip(dependencyZip); + + fixture.withArgs("traceRequirements") + .withReportFile(Path.of("build/reports/tracing.txt")) + .run() + .assertTraceOutcomeSuccessOrFromCache() + .assertReportFileLines( + "requirements-1.0.zip!spec.md:2", + "requirements-1.0.zip!source.java:1", + "not ok - 2 total, 1 direct, 0 transitive defects"); + } + + @Test + void publishToMavenRepo() + { + fixture(PUBLISH_CONFIG_DIR).withArgs("clean", "publishToMavenLocal") + .run() + .assertOutcome(":publishToMavenLocal", TaskOutcome.SUCCESS); + + final Path archive = PUBLISH_CONFIG_DIR + .resolve("build/distributions/publish-config-1.0.zip"); + assertTrue(Files.exists(archive)); + try (ZipFile zip = ZipFile.builder().setFile(archive.toFile()).get()) { - fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("tasks").run() - .assertOutput(containsString( - "traceRequirements - Trace requirements and generate tracing report")); + final String entryContent = readEntry(zip, "requirements.xml"); + assertThat(entryContent, containsString(""" + + + """)); + assertThat(entryContent, containsString(""" + + + exampleB + Tracing Example + approved + 1\ + """)); + assertThat(entryContent, containsString(""" + 2 + Example requirement + + utest + impl + + + """)); } - - @Test - void pluginUsesConfigurationCache() - { - testConfigurationCache(PROJECT_CUSTOM_CONFIG_DIR, - Path.of("build/custom-report.txt"), - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void pluginUsesConfigurationCacheWithMultiModuleProject() - { - testConfigurationCache(MULTI_PROJECT_DIR, Path.of("build/custom-report.txt"), - "ok - 6 total"); - } - - @Test - void pluginUsesConfigurationCacheWithImportedRequirements() - { - final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); - fixture.withArgs("clean").run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))); - - final Path dependencyZip = DEPENDENCY_CONFIG_DIR - .resolve("build/repo/requirements-1.0.zip"); - createDependencyZip(dependencyZip); - - testConfigurationCache(fixture, - Path.of("build/reports/tracing.txt"), - "requirements-1.0.zip!spec.md:2", - "requirements-1.0.zip!source.java:1", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - private void testConfigurationCache(final Path projectDir, final Path reportFile, - final String... lines) - { - testConfigurationCache(fixture(projectDir), reportFile, lines); - } - - private void testConfigurationCache(final PluginTestFixture fixture, final Path reportFile, - final String... lines) - { - fixture.withArgs("traceRequirements") - .withReportFile(reportFile) - .run() - .assertTraceOutcomeSuccessFromCacheOrUpToDate() - .assertReportFileLines(lines); - - fixture.withArgs("traceRequirements") - .withReportFile(reportFile) - .run() - .assertTraceOutcomeSuccessFromCacheOrUpToDate() - .assertReportFileLines(lines) - .assertOutput(containsString("Reusing configuration cache.")); - } - - @Test - void testTraceExampleProjectWithDefaultConfig() - { - fixture(PROJECT_DEFAULT_CONFIG_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/reports/tracing.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("ok - 0 total"); - } - - @Test - void testTraceExampleProjectWithPluginDependency() + catch (final IOException e) { - testConfigurationCache(PLUGIN_CONFIG_DIR, Path.of("build/reports/tracing.txt"), - "ok - 6 total"); + throw new UncheckedIOException("Failed to read zip file " + archive, e); } + } - @Test - void testCollectExampleProjectWithCustomConfig() + private static String readEntry(final ZipFile zip, final String entryName) + { + final ZipArchiveEntry reqirementsEntry = zip.getEntry(entryName); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(zip.getInputStream(reqirementsEntry)))) { - fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "collectRequirements") - .withReportFile(Path.of("build/reports/requirements.xml")) - .run().assertCollectOutcomeSuccessOrFromCache() - .assertReportFileLines( - "\n" + - "", - """ - - - exampleB\ - """, """ - - approved - 0 - """, - - """ - 1 - - - dsn:exampleB - 1 - - - """, - - """ - - - exampleB - Tracing Example - draft - 1 - """, - - """ - 2 - Example requirement - - utest - impl - - - """, - - " \n" + - ""); + return reader.lines().collect(joining("\n")); } - - @Test - void testCollectIsUpToDateWhenAlreadyRunBefore() - { - final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR); - fixture.withArgs("clean", "collectRequirements").run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))) - .assertCollectOutcomeSuccessOrFromCache(); - - fixture.withArgs("collectRequirements").run() - .assertOutcome(":collectRequirements", TaskOutcome.UP_TO_DATE); - } - - @Test - void testHtmlReportConfig() + catch (final IOException e) { - fixture(HTML_REPORT_CONFIG_DIR) - .withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/reports/tracing.html")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("", - "✗", - "
"); + throw new UncheckedIOException("Failed to read entry " + entryName, e); } + } - @Test - void testTraceTaskUpToDateWhenAlreadyRun() + private static void createDependencyZip(final Path dependencyZip) + { + TestUtil.createDirs(dependencyZip.getParent()); + try (ZipFileBuilder zipBuilder = ZipFileBuilder.create(dependencyZip)) { - final PluginTestFixture fixture = fixture(HTML_REPORT_CONFIG_DIR); - fixture.withArgs("clean", "traceRequirements").run() - .assertTraceOutcomeSuccessOrFromCache(); - fixture.withArgs("traceRequirements").run().assertOutcome(":traceRequirements", - TaskOutcome.UP_TO_DATE); + zipBuilder + .addEntry("source.java", + PROJECT_DEFAULT_CONFIG_DIR + .resolve("src/source.java")) // + .addEntry("spec.md", PROJECT_DEFAULT_CONFIG_DIR + .resolve("doc/spec.md")); } - - @Test - void testTraceExampleProjectWithCustomConfig() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void testTraceExampleProjectWithCustomConfigFailBuild() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true") - .withReportFile(Path.of("build/custom-report.txt")) - .runExpectingFailure() - .assertOutcome(":traceRequirements", TaskOutcome.FAILED) - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredArtifactTypes() + catch (final IOException e) { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true", - "-PfilteredArtifactTypes=dsn") - .run() - .assertTraceOutcomeSuccessOrFromCache(); + throw new UncheckedIOException( + "Failed to create dependency zip " + dependencyZip, e); } + } - @Test - void filteredWantedStatuses() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=draft,approved") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "not ok [ in: 1 / 1 ✔ | out: 0 / 0 ] dsn~exampleB~1 [draft] (impl, -utest)", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredWantedStatusesNoMatch() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=approved") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - // Generated ID depends on JVM - "not ok [ in: 0 / 0 | out: 0 / 1 ✘ ] impl~exampleB-", - "not ok - 1 total, 1 direct, 0 transitive defects"); - } - - @Test - void filteredWantedStatusesInvalidStatus() - { - fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", - "-PfilterWantedStatuses=invalid") - .runExpectingFailure() - .assertOutput(containsString( - "Invalid status 'invalid'. Valid statuses are: APPROVED, PROPOSED, DRAFT, REJECTED")); - } - - @Test - void testTraceExampleProjectWithCustomConfigFailBuildErrorMessage() - { - final PluginTestFixture fixture = fixture(PROJECT_CUSTOM_CONFIG_DIR) - .withArgs("clean", "traceRequirements", "-PfailBuild=true"); - final UnexpectedBuildFailure exception = assertThrows(UnexpectedBuildFailure.class, - fixture::run); - assertAll( - () -> assertEquals(TaskOutcome.FAILED, - exception.getBuildResult() - .task(":traceRequirements") - .getOutcome()), - () -> assertThat(exception.getMessage(), - startsWith("Unexpected build execution failure")), - () -> assertThat(exception.getMessage(), - containsString("Requirement tracing found 1 defects. See report at"))); - } - - @Test - void testTraceMultiProject() - { - fixture(MULTI_PROJECT_DIR).withArgs("clean", "traceRequirements") - .withReportFile(Path.of("build/custom-report.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines("ok - 6 total"); - } - - @Test - void traceDependencyProject() - { - final PluginTestFixture fixture = fixture(DEPENDENCY_CONFIG_DIR); - fixture.withArgs("clean") - .run() - .assertOutcome(":clean", - either(is(TaskOutcome.SUCCESS)) - .or(is(TaskOutcome.UP_TO_DATE))); - - final Path dependencyZip = DEPENDENCY_CONFIG_DIR - .resolve("build/repo/requirements-1.0.zip"); - createDependencyZip(dependencyZip); - - fixture.withArgs("traceRequirements") - .withReportFile(Path.of("build/reports/tracing.txt")) - .run() - .assertTraceOutcomeSuccessOrFromCache() - .assertReportFileLines( - "requirements-1.0.zip!spec.md:2", - "requirements-1.0.zip!source.java:1", - "not ok - 2 total, 1 direct, 0 transitive defects"); - } - - @Test - void publishToMavenRepo() - { - fixture(PUBLISH_CONFIG_DIR).withArgs("clean", "publishToMavenLocal") - .run() - .assertOutcome(":publishToMavenLocal", TaskOutcome.SUCCESS); - - final Path archive = PUBLISH_CONFIG_DIR - .resolve("build/distributions/publish-config-1.0.zip"); - assertTrue(Files.exists(archive)); - try (ZipFile zip = ZipFile.builder().setFile(archive.toFile()).get()) - { - final String entryContent = readEntry(zip, "requirements.xml"); - assertThat(entryContent, containsString(""" - - - """)); - assertThat(entryContent, containsString(""" - - - exampleB - Tracing Example - approved - 1\ - """)); - assertThat(entryContent, containsString(""" - 2 - Example requirement - - utest - impl - - - """)); - } - catch (final IOException e) - { - throw new UncheckedIOException("Failed to read zip file " + archive, e); - } - } - - private static String readEntry(final ZipFile zip, final String entryName) - { - final ZipArchiveEntry reqirementsEntry = zip.getEntry(entryName); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(zip.getInputStream(reqirementsEntry)))) - { - return reader.lines().collect(joining("\n")); - } - catch (final IOException e) - { - throw new UncheckedIOException("Failed to read entry " + entryName, e); - } - } - - private static void createDependencyZip(final Path dependencyZip) - { - TestUtil.createDirs(dependencyZip.getParent()); - try (ZipFileBuilder zipBuilder = ZipFileBuilder.create(dependencyZip)) - { - zipBuilder - .addEntry("source.java", - PROJECT_DEFAULT_CONFIG_DIR - .resolve("src/source.java")) // - .addEntry("spec.md", PROJECT_DEFAULT_CONFIG_DIR - .resolve("doc/spec.md")); - } - catch (final IOException e) - { - throw new UncheckedIOException( - "Failed to create dependency zip " + dependencyZip, e); - } - } - - private PluginTestFixture fixture(final Path projectDir) - { - return PluginTestFixture.create(config, projectDir); - } + private PluginTestFixture fixture(final Path projectDir) + { + return PluginTestFixture.create(config, projectDir); + } } From e5108bb9f237744d05c66d72be1ebed22fb14473 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Mon, 31 Aug 2026 14:57:46 +0200 Subject: [PATCH 03/17] Add integration test for asciidoc plugin --- .../openfasttrace/gradle/OpenFastTracePluginTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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() { From 99c1ab80903c666c4100b11946b61eac0e9f50b6 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Mon, 31 Aug 2026 14:58:01 +0200 Subject: [PATCH 04/17] Add workaround for missing class in OFT --- .../RegexMatchingImporterFactory.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java diff --git a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java new file mode 100644 index 0000000..8472594 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java @@ -0,0 +1,34 @@ +package org.itsallcode.openfasttrace.api.importer; + +import java.util.Collection; + +/** + * Compatibility shim for RegexMatchingImporterFactory which was renamed to + * AbstractRegexMatchingImporterFactory in OpenFastTrace 4.5.0. + *

+ * Shim can be removed when the following issue is fixed: + * + * itsallcode/openffasttrace-asciidoc-plugin # 27 + * + *

+ * Copied from + * OpenFastTrace + * Maven Plugin + * + * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. + */ +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("java:S118") // Shim class. Ignore name convention. +public abstract class RegexMatchingImporterFactory extends AbstractRegexMatchingImporterFactory +{ + protected RegexMatchingImporterFactory(final String... extensions) + { + super(extensions); + } + + protected RegexMatchingImporterFactory(final Collection extensions) + { + super(extensions); + } +} \ No newline at end of file From cd9eb163d6249b829341fbe03770d5227f8f0db5 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Mon, 31 Aug 2026 14:58:11 +0200 Subject: [PATCH 05/17] Code cleanup --- .../openfasttrace/gradle/PluginTestFixture.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java b/src/test/java/org/itsallcode/openfasttrace/gradle/PluginTestFixture.java index 8951308..7ca8225 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); } From 7712b7f847a091175ab98498549c2576d759e4cd Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Mon, 31 Aug 2026 15:14:57 +0200 Subject: [PATCH 06/17] Improve child first class loader --- .../gradle/task/ChildFirstClassLoader.java | 72 +++++++++++++++++++ .../gradle/task/OftPluginClassLoader.java | 48 +++++-------- 2 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java new file mode 100644 index 0000000..0ebc333 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java @@ -0,0 +1,72 @@ +package org.itsallcode.openfasttrace.gradle.task; + +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 + { + final Class loadedClass = findClass(name, resolve); + if (resolve) + { + resolveClass(loadedClass); + } + return loadedClass; + } + + 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); + } + + @SuppressWarnings("java:S3032") // Intentionally accessing non-standard classloader + 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/OftPluginClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java index 9a2e157..f08d2f4 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java @@ -3,6 +3,7 @@ import java.io.File; import java.net.*; import java.util.Arrays; +import java.util.Objects; import org.gradle.api.file.FileCollection; import org.itsallcode.openfasttrace.core.OftRunner; @@ -49,8 +50,7 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab } catch (final java.io.IOException e) { - throw new IllegalStateException("Could not close OpenFastTrace plugin classloader", - e); + throw new IllegalStateException("Could not close OpenFastTrace plugin classloader", e); } } } @@ -58,40 +58,24 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab private static URLClassLoader createClassLoader(final FileCollection pluginFiles, final ClassLoader parent) { - final URL[] pluginUrls = pluginFiles.getFiles().stream().map(File::toURI) - .map(uri -> { - try - { - return uri.toURL(); - } - catch (final MalformedURLException e) - { - throw new IllegalArgumentException("Invalid plugin file URL: " + uri, - e); - } - }).toArray(URL[]::new); - return new ChildFirstClassLoader(pluginUrls, parent); + final URL[] pluginUrls = pluginFiles.getFiles().stream() + .map(File::toURI) + .map(OftPluginClassLoader::toUrl) + .toArray(URL[]::new); + + return new ChildFirstClassLoader("ChildFirst ClassLoader for " + Arrays.toString(pluginUrls), pluginUrls, + parent); } - private static final class ChildFirstClassLoader extends URLClassLoader + private static URL toUrl(final URI uri) { - private ChildFirstClassLoader(final URL[] urls, final ClassLoader parent) + try { - super(urls, parent); + return uri.toURL(); } - - @Override - protected Class loadClass(final String name, final boolean resolve) - throws ClassNotFoundException + catch (final MalformedURLException e) { - try - { - return findClass(name); - } - catch (final ClassNotFoundException e) - { - return super.loadClass(name, resolve); - } + throw new IllegalArgumentException("Invalid plugin file URL: " + uri, e); } } @@ -102,7 +86,9 @@ private static final class ParentClassLoader extends ClassLoader private ParentClassLoader(final ClassLoader... parents) { super(null); - this.parents = Arrays.stream(parents).filter(parent -> parent != null).distinct() + this.parents = Arrays.stream(parents) + .filter(Objects::nonNull) + .distinct() .toArray(ClassLoader[]::new); } From 26f38ebb8b1687999de4484c90f13b05e914f562 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 3 Sep 2026 17:37:04 +0200 Subject: [PATCH 07/17] Code cleanup --- example-projects/plugin-config/build.gradle | 1 + .../gradle/OpenFastTracePlugin.java | 14 +++-- .../gradle/task/CollectTask.java | 26 +++++---- .../openfasttrace/gradle/task/TraceTask.java | 54 ++++++++++--------- .../ChildFirstClassLoader.java | 2 +- .../OftPluginClassLoader.java | 51 ++++-------------- .../task/classloader/ParentClassLoader.java | 35 ++++++++++++ 7 files changed, 98 insertions(+), 85 deletions(-) rename src/main/java/org/itsallcode/openfasttrace/gradle/task/{ => classloader}/ChildFirstClassLoader.java (97%) rename src/main/java/org/itsallcode/openfasttrace/gradle/task/{ => classloader}/OftPluginClassLoader.java (61%) create mode 100644 src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java diff --git a/example-projects/plugin-config/build.gradle b/example-projects/plugin-config/build.gradle index 132496f..8b4748c 100644 --- a/example-projects/plugin-config/build.gradle +++ b/example-projects/plugin-config/build.gradle @@ -11,5 +11,6 @@ requirementTracing { failBuild = true inputDirectories = files('doc', 'src') reportFormat = 'plain' + // Once we upgrade this, we can remove RegexMatchingImporterFactory pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:0.3.0'] } diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java index 9ec823f..458a948 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java @@ -167,23 +167,21 @@ private static Configuration getImportedRequirements(final Project project) private static ConfigurableFileCollection getPluginDependencies(final Project rootProject, final Set allProjects) { - return rootProject.files(allProjects.stream() // - .map(OpenFastTracePlugin::getPluginDependencies) // + return rootProject.files(allProjects.stream() + .map(OpenFastTracePlugin::getPluginDependencies) .toList()); } private static Configuration getPluginDependencies(final Project project) { final String CONFIG_NAME = "oftPluginConfig"; - return getOrCreateConfiguration(project, CONFIG_NAME, - getConfig(project).getPluginDependencies().get()); + return getOrCreateConfiguration(project, CONFIG_NAME, getConfig(project).getPluginDependencies().get()); } - private static Configuration getOrCreateConfiguration(final Project project, - final String configurationName, final List dependencies) + private static Configuration getOrCreateConfiguration(final Project project, final String configurationName, + final List dependencies) { - final Configuration existingConfiguration = project.getConfigurations() - .findByName(configurationName); + final Configuration existingConfiguration = project.getConfigurations().findByName(configurationName); if (existingConfiguration != null) { return existingConfiguration; 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 d1df9a7..9057097 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/CollectTask.java @@ -18,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. */ @@ -96,17 +97,20 @@ public ConfigurableFileCollection getPluginFiles() public void collectRequirements() { createReportOutputDir(); - OftPluginClassLoader.runWithPlugins(pluginFiles, () -> { - final Oft oft = new OftRunner(); - final ImportSettings settings = getImportSettings(); - getLogger().info("Importing from {} locations {} and {} path configurations: {}", - settings.getInputs().size(), settings.getInputs(), settings.getPathConfigs().size(), - settings.getPathConfigs()); - final List importedItems = oft.importItems(settings); - final Path output = getOuputFileInternal().toPath(); - getLogger().info("Imported {} spec items, writing to {}", importedItems.size(), output); - oft.exportToPath(importedItems, output, getExportSettings()); - }); + 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: {}", + settings.getInputs().size(), settings.getInputs(), settings.getPathConfigs().size(), + settings.getPathConfigs()); + final List importedItems = oft.importItems(settings); + final Path output = getOuputFileInternal().toPath(); + getLogger().info("Imported {} spec items, writing to {}", importedItems.size(), output); + oft.exportToPath(importedItems, output, getExportSettings()); } private static ExportSettings getExportSettings() 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 f6ff934..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") @@ -214,33 +215,36 @@ private boolean shouldFailBuild() public void trace() { createReportOutputDir(); - OftPluginClassLoader.runWithPlugins(pluginFiles, () -> { - final Oft oft = new OftRunner(); - final ImportSettings importSettings = getImportSettings(); - final List importedItems = oft.importItems(importSettings); - getLogger().info("Read {} spec items from {}", importedItems.size(), - importSettings.getInputs()); - final List linkedItems = oft.link(importedItems); - final Trace trace = oft.trace(linkedItems); - final Path reportPath = getOutputFileInternal().toPath(); - getLogger().info("Tracing result: {} total items, {} defects. Writing report to {}", - trace.count(), trace.countDefects(), reportPath); - oft.reportToPath(trace, reportPath, getReportSettings()); - if (trace.countDefects() > 0) - { - final String message = "Requirement tracing found " + trace.countDefects() - + " defects. See report at " + reportPath + " for details."; - if (shouldFailBuild()) - { - throw new IllegalStateException(message); - } - getLogger().warn(message); - } - else + OftPluginClassLoader.runWithPlugins(pluginFiles, this::traceWithPlugins); + } + + private void traceWithPlugins() + { + final Oft oft = new OftRunner(); + final ImportSettings importSettings = getImportSettings(); + final List importedItems = oft.importItems(importSettings); + getLogger().info("Read {} spec items from {}", importedItems.size(), + importSettings.getInputs()); + final List linkedItems = oft.link(importedItems); + final Trace trace = oft.trace(linkedItems); + final Path reportPath = getOutputFileInternal().toPath(); + getLogger().info("Tracing result: {} total items, {} defects. Writing report to {}", + trace.count(), trace.countDefects(), reportPath); + oft.reportToPath(trace, reportPath, getReportSettings()); + if (trace.countDefects() > 0) + { + final String message = "Requirement tracing found " + trace.countDefects() + + " defects. See report at " + reportPath + " for details."; + if (shouldFailBuild()) { - getLogger().info("Requirement tracing completed successfully."); + throw new IllegalStateException(message); } - }); + getLogger().warn(message); + } + else + { + getLogger().info("Requirement tracing completed successfully."); + } } private ReportSettings getReportSettings() diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java similarity index 97% rename from src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java rename to src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java index 0ebc333..c5f2841 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/ChildFirstClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java @@ -1,4 +1,4 @@ -package org.itsallcode.openfasttrace.gradle.task; +package org.itsallcode.openfasttrace.gradle.task.classloader; import java.net.URL; import java.net.URLClassLoader; diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java similarity index 61% rename from src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java rename to src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java index f08d2f4..37a5779 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -1,19 +1,22 @@ -package org.itsallcode.openfasttrace.gradle.task; +package org.itsallcode.openfasttrace.gradle.task.classloader; import java.io.File; +import java.io.IOException; import java.net.*; import java.util.Arrays; -import java.util.Objects; 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() { - super(); } /** @@ -48,23 +51,21 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab { pluginClassLoader.close(); } - catch (final java.io.IOException e) + catch (final IOException e) { - throw new IllegalStateException("Could not close OpenFastTrace plugin classloader", e); + LOG.warn("Could not close OpenFastTrace plugin classloader", e); } } } - private static URLClassLoader createClassLoader(final FileCollection pluginFiles, - final ClassLoader parent) + private static URLClassLoader createClassLoader(final FileCollection pluginFiles, final ClassLoader parent) { final URL[] pluginUrls = pluginFiles.getFiles().stream() .map(File::toURI) .map(OftPluginClassLoader::toUrl) .toArray(URL[]::new); - - return new ChildFirstClassLoader("ChildFirst ClassLoader for " + Arrays.toString(pluginUrls), pluginUrls, - parent); + final String pluginUrlsString = Arrays.toString(pluginUrls); + return new ChildFirstClassLoader("ChildFirst ClassLoader for " + pluginUrlsString, pluginUrls, parent); } private static URL toUrl(final URI uri) @@ -78,34 +79,4 @@ private static URL toUrl(final URI uri) throw new IllegalArgumentException("Invalid plugin file URL: " + uri, e); } } - - private static final class ParentClassLoader extends ClassLoader - { - private final ClassLoader[] parents; - - private ParentClassLoader(final ClassLoader... parents) - { - super(null); - this.parents = Arrays.stream(parents) - .filter(Objects::nonNull) - .distinct() - .toArray(ClassLoader[]::new); - } - - @Override - 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) - {} - } - throw new ClassNotFoundException(name); - } - } } 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..168a441 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java @@ -0,0 +1,35 @@ +package org.itsallcode.openfasttrace.gradle.task.classloader; + +import java.util.Arrays; +import java.util.Objects; + +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 + 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); + } +} \ No newline at end of file From 884bcb80c8dec47d420472aa37f5713dc234f4b8 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 4 Sep 2026 11:48:38 +0200 Subject: [PATCH 08/17] Fix classpath issues with OFT --- .../classloader/ChildFirstClassLoader.java | 10 +++++ .../classloader/OftPluginClassLoader.java | 45 +++++++++++++++---- .../task/classloader/ParentClassLoader.java | 41 ++++++++++++++++- 3 files changed, 85 insertions(+), 11 deletions(-) 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 index c5f2841..39d201f 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java @@ -31,6 +31,10 @@ class ChildFirstClassLoader extends URLClassLoader @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) { @@ -39,6 +43,12 @@ protected Class loadClass(final String name, final boolean resolve) throws Cl 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? 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 index 37a5779..f6996b8 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -1,9 +1,8 @@ package org.itsallcode.openfasttrace.gradle.task.classloader; -import java.io.File; import java.io.IOException; import java.net.*; -import java.util.Arrays; +import java.util.*; import org.gradle.api.file.FileCollection; import org.gradle.api.logging.Logger; @@ -37,8 +36,8 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab final Thread thread = Thread.currentThread(); final ClassLoader originalClassLoader = thread.getContextClassLoader(); - final URLClassLoader pluginClassLoader = createClassLoader(pluginFiles, - new ParentClassLoader(originalClassLoader, OftRunner.class.getClassLoader())); + final ClassLoader parent = new ParentClassLoader(OftRunner.class.getClassLoader(), originalClassLoader); + final URLClassLoader pluginClassLoader = createClassLoader(pluginFiles, parent); thread.setContextClassLoader(pluginClassLoader); try { @@ -60,12 +59,40 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab private static URLClassLoader createClassLoader(final FileCollection pluginFiles, final ClassLoader parent) { - final URL[] pluginUrls = pluginFiles.getFiles().stream() - .map(File::toURI) + final Set pluginUrls = new HashSet<>(pluginFiles.getFiles().stream() + .map(file -> file.toPath().toUri()) .map(OftPluginClassLoader::toUrl) - .toArray(URL[]::new); - final String pluginUrlsString = Arrays.toString(pluginUrls); - return new ChildFirstClassLoader("ChildFirst ClassLoader for " + pluginUrlsString, pluginUrls, parent); + .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"); + final URL[] urls = pluginUrls.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(jarConnection.getJarFileURL()); + } + } + } + catch (final IOException e) + { + throw new IllegalStateException("Could not locate service provider jars for " + serviceName, e); + } } private static URL toUrl(final URI uri) 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 index 168a441..92b6dda 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java @@ -1,7 +1,8 @@ package org.itsallcode.openfasttrace.gradle.task.classloader; -import java.util.Arrays; -import java.util.Objects; +import java.io.IOException; +import java.net.URL; +import java.util.*; final class ParentClassLoader extends ClassLoader { @@ -32,4 +33,40 @@ protected Class loadClass(final String name, final boolean resolve) throws Cl } 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); + } + } } \ No newline at end of file From 50425bfdf9513efc4e3c7814de0bb55ccbe1f541 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Sep 2026 11:06:04 +0200 Subject: [PATCH 09/17] Fix missing javadoc comment --- .../api/importer/RegexMatchingImporterFactory.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java index 8472594..d8e1502 100644 --- a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java +++ b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java @@ -22,11 +22,23 @@ @SuppressWarnings("java:S118") // Shim class. Ignore name convention. public abstract class RegexMatchingImporterFactory extends AbstractRegexMatchingImporterFactory { + /** + * Constructs a new RegexMatchingImporterFactory with the specified file extensions. + * + * @param extensions + * the file extensions to be associated with this importer factory + */ protected RegexMatchingImporterFactory(final String... extensions) { super(extensions); } + /** + * Constructs a new RegexMatchingImporterFactory with the specified file extensions. + * + * @param extensions + * the file extensions to be associated with this importer factory + */ protected RegexMatchingImporterFactory(final Collection extensions) { super(extensions); From a8b7af16f957a3a53dd76e0e861d89014f8e8310 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Sep 2026 11:26:17 +0200 Subject: [PATCH 10/17] Upgrade github actions --- .github/workflows/build.yml | 4 ++-- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2c8568..4fa5e56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-java@v5 + - uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: ${{ matrix.java }} @@ -78,7 +78,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/setup-java@v5 + - uses: actions/setup-java@v6 with: distribution: temurin java-version: 17 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f6a34d0..c88894e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,7 +28,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-java@v5 + - uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: 17 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e328fdb..b783a14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: script: | core.setFailed('Not running on main branch, github.ref is ${{ github.ref }}. Please start this workflow only on main') - - uses: actions/setup-java@v5 + - uses: actions/setup-java@v6 with: distribution: "temurin" java-version: 17 From 5a093b0aa84243b3cbc267fc8574e936c9535fbb Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Sep 2026 11:27:11 +0200 Subject: [PATCH 11/17] Suppress sonar warnings --- .../gradle/task/classloader/ChildFirstClassLoader.java | 1 - .../openfasttrace/gradle/task/classloader/ParentClassLoader.java | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) 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 index 39d201f..68b71e6 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ChildFirstClassLoader.java @@ -60,7 +60,6 @@ private Class findClass(final String name, final boolean resolve) throws Clas return loadClassInternally(name, resolve); } - @SuppressWarnings("java:S3032") // Intentionally accessing non-standard classloader private Class loadClassInternally(final String name, final boolean resolve) throws ClassNotFoundException { try 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 index 92b6dda..ef11e8c 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/ParentClassLoader.java @@ -18,6 +18,7 @@ final class ParentClassLoader extends ClassLoader } @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) From 8f11afb5ca3e18738c302b6d50ea3803fa2dfc00 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 5 Sep 2026 11:33:28 +0200 Subject: [PATCH 12/17] Fix sonar warnings --- .../importer/RegexMatchingImporterFactory.java | 6 +++++- .../task/classloader/OftPluginClassLoader.java | 15 +++++++-------- .../task/classloader/ParentClassLoader.java | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java index d8e1502..55ab514 100644 --- a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java +++ b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java @@ -27,7 +27,9 @@ public abstract class RegexMatchingImporterFactory extends AbstractRegexMatching * * @param extensions * the file extensions to be associated with this importer factory + * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. */ + @Deprecated(since = "3.2.0", forRemoval = true) protected RegexMatchingImporterFactory(final String... extensions) { super(extensions); @@ -38,9 +40,11 @@ protected RegexMatchingImporterFactory(final String... extensions) * * @param extensions * the file extensions to be associated with this importer factory + * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. */ + @Deprecated(since = "3.2.0", forRemoval = true) protected RegexMatchingImporterFactory(final Collection extensions) { super(extensions); } -} \ No newline at end of file +} 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 index f6996b8..a941fca 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -59,9 +59,8 @@ public static void runWithPlugins(final FileCollection pluginFiles, final Runnab private static URLClassLoader createClassLoader(final FileCollection pluginFiles, final ClassLoader parent) { - final Set pluginUrls = new HashSet<>(pluginFiles.getFiles().stream() + final Set pluginUrls = new HashSet<>(pluginFiles.getFiles().stream() .map(file -> file.toPath().toUri()) - .map(OftPluginClassLoader::toUrl) .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. @@ -74,7 +73,7 @@ private static URLClassLoader createClassLoader(final FileCollection pluginFiles return new ChildFirstClassLoader("ChildFirst ClassLoader for " + pluginUrlsString, urls, parent); } - private static void addServiceProviderJars(final ClassLoader parent, final Set urls, + private static void addServiceProviderJars(final ClassLoader parent, final Set urls, final String serviceName) { try @@ -85,7 +84,7 @@ private static void addServiceProviderJars(final ClassLoader parent, final Set getResources(final ClassLoader parent, final String nam throw new IllegalStateException("Could not get resources for " + name, e); } } -} \ No newline at end of file +} From c5b82231ab653a53608476107311ec68b0bf9ece Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Thu, 24 Sep 2026 20:07:39 +0200 Subject: [PATCH 13/17] Fix merge errors --- .../gradle/OpenFastTracePlugin.java | 39 +++++++++++++++++++ .../classloader/OftPluginClassLoader.java | 16 +++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java index a7884d6..da16676 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/OpenFastTracePlugin.java @@ -177,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/task/classloader/OftPluginClassLoader.java b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java index a941fca..921ff39 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -68,7 +68,9 @@ private static URLClassLoader createClassLoader(final FileCollection pluginFiles "org.itsallcode.openfasttrace.api.exporter.ExporterFactory"); addServiceProviderJars(parent, pluginUrls, "org.itsallcode.openfasttrace.api.importer.ImporterFactory"); - final URL[] urls = pluginUrls.toArray(URL[]::new); + 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); } @@ -105,4 +107,16 @@ private static URI toUri(final URL url) 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); + } + } } From f97db31ba474fba8d67703f178241569fd72f10a Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 25 Sep 2026 18:31:14 +0200 Subject: [PATCH 14/17] Upgrade asciidoc plugin --- README.md | 2 +- example-projects/plugin-config/build.gradle | 2 +- .../RegexMatchingImporterFactory.java | 50 ------------------- 3 files changed, 2 insertions(+), 52 deletions(-) delete mode 100644 src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java diff --git a/README.md b/README.md index 4ad9ded..a8ddbc2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ repositories { } requirementTracing { - pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:0.3.0'] + pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:1.0.0'] } ``` diff --git a/example-projects/plugin-config/build.gradle b/example-projects/plugin-config/build.gradle index 8b4748c..86e76b4 100644 --- a/example-projects/plugin-config/build.gradle +++ b/example-projects/plugin-config/build.gradle @@ -12,5 +12,5 @@ requirementTracing { inputDirectories = files('doc', 'src') reportFormat = 'plain' // Once we upgrade this, we can remove RegexMatchingImporterFactory - pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:0.3.0'] + pluginDependencies = ['org.itsallcode:openfasttrace-asciidoc-plugin:1.0.0'] } diff --git a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java b/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java deleted file mode 100644 index 55ab514..0000000 --- a/src/main/java/org/itsallcode/openfasttrace/api/importer/RegexMatchingImporterFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.itsallcode.openfasttrace.api.importer; - -import java.util.Collection; - -/** - * Compatibility shim for RegexMatchingImporterFactory which was renamed to - * AbstractRegexMatchingImporterFactory in OpenFastTrace 4.5.0. - *

- * Shim can be removed when the following issue is fixed: - * - * itsallcode/openffasttrace-asciidoc-plugin # 27 - * - *

- * Copied from - * OpenFastTrace - * Maven Plugin - * - * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. - */ -@Deprecated(since = "3.2.0", forRemoval = true) -@SuppressWarnings("java:S118") // Shim class. Ignore name convention. -public abstract class RegexMatchingImporterFactory extends AbstractRegexMatchingImporterFactory -{ - /** - * Constructs a new RegexMatchingImporterFactory with the specified file extensions. - * - * @param extensions - * the file extensions to be associated with this importer factory - * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. - */ - @Deprecated(since = "3.2.0", forRemoval = true) - protected RegexMatchingImporterFactory(final String... extensions) - { - super(extensions); - } - - /** - * Constructs a new RegexMatchingImporterFactory with the specified file extensions. - * - * @param extensions - * the file extensions to be associated with this importer factory - * @deprecated use {@link AbstractRegexMatchingImporterFactory} instead. - */ - @Deprecated(since = "3.2.0", forRemoval = true) - protected RegexMatchingImporterFactory(final Collection extensions) - { - super(extensions); - } -} From 36c41c7c3a93de6a419f433fae31472fe4309127 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 25 Sep 2026 18:33:03 +0200 Subject: [PATCH 15/17] Fix issue with reporter factory --- .../gradle/task/classloader/OftPluginClassLoader.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 921ff39..a0cc872 100644 --- a/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java +++ b/src/main/java/org/itsallcode/openfasttrace/gradle/task/classloader/OftPluginClassLoader.java @@ -68,6 +68,8 @@ private static URLClassLoader createClassLoader(final FileCollection pluginFiles "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); From 1f0e866e0cf0d14a62a94173ea268954b75305dc Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 25 Sep 2026 18:38:05 +0200 Subject: [PATCH 16/17] Upgrade gradle version --- gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 52 ++++++++++++++---- .../gradle/GradleTestConfig.java | 3 +- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 38775 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 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/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; From f2e1dc7441532947b88ece9fb35d20bb9f0cb930 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 26 Sep 2026 12:45:37 +0200 Subject: [PATCH 17/17] Add plan to simplify plugin loading --- docs/plugin-loading-refactor-plan.md | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/plugin-loading-refactor-plan.md 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.