From 4340ea358013eec3f33f300a47ab724b1150ebd6 Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Fri, 21 Aug 2026 14:25:09 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 +}