diff --git a/src/main/java/io/github/reqstool/gradle/RequirementsToolExtension.java b/src/main/java/io/github/reqstool/gradle/RequirementsToolExtension.java index 1343877..e769762 100644 --- a/src/main/java/io/github/reqstool/gradle/RequirementsToolExtension.java +++ b/src/main/java/io/github/reqstool/gradle/RequirementsToolExtension.java @@ -1,9 +1,15 @@ package io.github.reqstool.gradle; import org.gradle.api.Project; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.tasks.SourceSet; + +import java.util.Collections; +import java.util.stream.Collectors; /** * Extension for configuring the Reqstool Gradle Plugin. Provides zero-configuration @@ -13,7 +19,7 @@ public class RequirementsToolExtension { private final RegularFileProperty requirementsAnnotationsFile; - private final RegularFileProperty svcsAnnotationsFile; + private final ConfigurableFileCollection svcsAnnotationsFiles; private final RegularFileProperty outputDirectory; @@ -34,7 +40,7 @@ public class RequirementsToolExtension { */ public RequirementsToolExtension(Project project) { this.requirementsAnnotationsFile = project.getObjects().fileProperty(); - this.svcsAnnotationsFile = project.getObjects().fileProperty(); + this.svcsAnnotationsFiles = project.getObjects().fileCollection(); this.outputDirectory = project.getObjects().fileProperty(); this.datasetPath = project.getObjects().fileProperty(); this.testResults = project.getObjects().listProperty(String.class); @@ -46,9 +52,26 @@ public RequirementsToolExtension(Project project) { this.requirementsAnnotationsFile.convention(project.getLayout() .getBuildDirectory() .file("generated/sources/annotationProcessor/java/main/resources/annotations.yml")); - this.svcsAnnotationsFile.convention(project.getLayout() - .getBuildDirectory() - .file("generated/sources/annotationProcessor/java/test/resources/annotations.yml")); + this.svcsAnnotationsFiles.from(project.provider(() -> { + JavaPluginExtension javaExt = project.getExtensions().findByType(JavaPluginExtension.class); + if (javaExt != null) { + return javaExt.getSourceSets() + .stream() + .filter(ss -> !SourceSet.MAIN_SOURCE_SET_NAME.equals(ss.getName())) + .map(ss -> project.getLayout() + .getBuildDirectory() + .file("generated/sources/annotationProcessor/java/" + ss.getName() + + "/resources/annotations.yml") + .get() + .getAsFile()) + .collect(Collectors.toList()); + } + return Collections.singletonList(project.getLayout() + .getBuildDirectory() + .file("generated/sources/annotationProcessor/java/test/resources/annotations.yml") + .get() + .getAsFile()); + })); this.outputDirectory.convention(project.getObjects() .fileProperty() .fileProvider(project.getLayout().getBuildDirectory().map(d -> d.dir("reqstool").getAsFile()))); @@ -74,12 +97,12 @@ public RegularFileProperty getRequirementsAnnotationsFile() { } /** - * Returns the path to the SVCs (Software Verification Cases) annotations YAML file - * generated by the test annotation processor. - * @return file property for SVCs annotations + * Returns the collection of SVCs (Software Verification Cases) annotations YAML files + * generated by the annotation processor for each test source set. + * @return file collection for SVCs annotations */ - public RegularFileProperty getSvcsAnnotationsFile() { - return svcsAnnotationsFile; + public ConfigurableFileCollection getSvcsAnnotationsFiles() { + return svcsAnnotationsFiles; } /** diff --git a/src/main/java/io/github/reqstool/gradle/RequirementsToolPlugin.java b/src/main/java/io/github/reqstool/gradle/RequirementsToolPlugin.java index d30f2f3..7d599b4 100644 --- a/src/main/java/io/github/reqstool/gradle/RequirementsToolPlugin.java +++ b/src/main/java/io/github/reqstool/gradle/RequirementsToolPlugin.java @@ -39,7 +39,7 @@ public void apply(Project project) { // Configure task inputs from extension task.getRequirementsAnnotationsFile().set(extension.getRequirementsAnnotationsFile()); - task.getSvcsAnnotationsFile().set(extension.getSvcsAnnotationsFile()); + task.getSvcsAnnotationsFiles().setFrom(extension.getSvcsAnnotationsFiles()); task.getOutputDirectory().set(extension.getOutputDirectory()); task.getDatasetPath().set(extension.getDatasetPath()); task.getTestResults().set(extension.getTestResults()); diff --git a/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java b/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java index 85a4477..c6d8b5c 100644 --- a/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java +++ b/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java @@ -3,17 +3,20 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputDirectory; import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; @@ -102,7 +105,7 @@ public class RequirementsToolTask extends DefaultTask { private final RegularFileProperty requirementsAnnotationsFile = getProject().getObjects().fileProperty(); - private final RegularFileProperty svcsAnnotationsFile = getProject().getObjects().fileProperty(); + private final ConfigurableFileCollection svcsAnnotationsFiles = getProject().getObjects().fileCollection(); private final RegularFileProperty outputDirectory = getProject().getObjects().fileProperty(); @@ -142,14 +145,15 @@ public RegularFileProperty getRequirementsAnnotationsFile() { } /** - * Returns the path to the SVCs (Software Verification Cases) annotations YAML file. - * @return file property for SVCs annotations + * Returns the collection of SVCs (Software Verification Cases) annotations YAML + * files, one per test source set. + * @return file collection for SVCs annotations */ + @InputFiles @Optional - @InputFile @PathSensitive(PathSensitivity.NONE) - public RegularFileProperty getSvcsAnnotationsFile() { - return svcsAnnotationsFile; + public ConfigurableFileCollection getSvcsAnnotationsFiles() { + return svcsAnnotationsFiles; } /** @@ -280,10 +284,16 @@ public void execute() { .path(XML_IMPLEMENTATIONS); } - File svcsAnnotFile = svcsAnnotationsFile.getAsFile().getOrNull(); - if (svcsAnnotFile != null && svcsAnnotFile.exists()) { - testsNode = yamlMapper.readTree(svcsAnnotFile).path(XML_REQUIREMENT_ANNOTATIONS).path(XML_TESTS); + ObjectNode mergedTestsNode = yamlMapper.createObjectNode(); + for (File svcsAnnotFile : svcsAnnotationsFiles.getFiles()) { + if (svcsAnnotFile.exists()) { + JsonNode fileTestsNode = yamlMapper.readTree(svcsAnnotFile) + .path(XML_REQUIREMENT_ANNOTATIONS) + .path(XML_TESTS); + mergeTestNodes(mergedTestsNode, fileTestsNode); + } } + testsNode = mergedTestsNode.isEmpty() ? yamlMapper.createObjectNode() : mergedTestsNode; JsonNode combinedOutputNode = combineOutput(implementationsNode, testsNode); @@ -329,6 +339,29 @@ static JsonNode combineOutput(JsonNode implementationsNode, JsonNode testsNode) return newNode; } + /** + * Deep-merges source tests node into target. For duplicate SVC keys, concatenates the + * entry arrays; otherwise adds the key. + * @param target the node to merge into + * @param source the node to merge from + */ + static void mergeTestNodes(ObjectNode target, JsonNode source) { + if (!source.isObject()) { + return; + } + source.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode incoming = entry.getValue(); + if (target.has(key) && target.get(key).isArray() && incoming.isArray()) { + ArrayNode existing = (ArrayNode) target.get(key); + incoming.forEach(existing::add); + } + else { + target.set(key, incoming.deepCopy()); + } + }); + } + /** * Writes the combined annotations to a YAML file with language server schema hints. * @param outputFile the file to write to @@ -337,10 +370,9 @@ static JsonNode combineOutput(JsonNode implementationsNode, JsonNode testsNode) */ private void writeCombinedOutputToFile(File outputFile, JsonNode combinedOutputNode) throws IOException { File reqAnnotFile = requirementsAnnotationsFile.getAsFile().getOrNull(); - File svcsAnnotFile = svcsAnnotationsFile.getAsFile().getOrNull(); - getLogger() - .info("Combining " + reqAnnotFile + " and " + svcsAnnotFile + " into " + outputFile.getAbsolutePath()); + getLogger().info("Combining " + reqAnnotFile + " and " + svcsAnnotationsFiles.getFiles() + " into " + + outputFile.getAbsolutePath()); try (Writer writer = new PrintWriter( new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8))) { diff --git a/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java b/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java index 76ee819..2b7338e 100644 --- a/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java +++ b/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java @@ -1,6 +1,7 @@ package io.github.reqstool.gradle; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -9,6 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.util.List; import org.gradle.api.Project; import org.gradle.testfixtures.ProjectBuilder; @@ -18,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; class RequirementsToolTaskTest { @@ -110,7 +113,7 @@ void testTaskConfiguration() { .create("requirementsTool", RequirementsToolExtension.class, project); task.getRequirementsAnnotationsFile().set(extension.getRequirementsAnnotationsFile()); - task.getSvcsAnnotationsFile().set(extension.getSvcsAnnotationsFile()); + task.getSvcsAnnotationsFiles().setFrom(extension.getSvcsAnnotationsFiles()); task.getOutputDirectory().set(extension.getOutputDirectory()); task.getDatasetPath().set(extension.getDatasetPath()); task.getTestResults().set(extension.getTestResults()); @@ -139,6 +142,32 @@ void testSkipExecution() { assertDoesNotThrow(() -> task.execute()); } + @Test + void testMergeTestNodes_mergesTwoFiles() throws IOException { + String yaml1 = "requirement_annotations:\n tests:\n" + + " SVC_001:\n - elementKind: METHOD\n fullyQualifiedName: pkg.TestA.test1\n" + + " SVC_002:\n - elementKind: METHOD\n fullyQualifiedName: pkg.TestA.test2\n"; + String yaml2 = "requirement_annotations:\n tests:\n" + + " SVC_001:\n - elementKind: METHOD\n fullyQualifiedName: pkg.TestB.test3\n" + + " SVC_003:\n - elementKind: METHOD\n fullyQualifiedName: pkg.TestB.test4\n"; + + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + File f1 = tempDir.resolve("annot1.yml").toFile(); + File f2 = tempDir.resolve("annot2.yml").toFile(); + java.nio.file.Files.writeString(f1.toPath(), yaml1); + java.nio.file.Files.writeString(f2.toPath(), yaml2); + + ObjectNode mergedTests = mapper.createObjectNode(); + for (File f : List.of(f1, f2)) { + JsonNode testNode = mapper.readTree(f).path("requirement_annotations").path("tests"); + RequirementsToolTask.mergeTestNodes(mergedTests, testNode); + } + + assertEquals(2, mergedTests.get("SVC_001").size()); + assertTrue(mergedTests.has("SVC_002")); + assertTrue(mergedTests.has("SVC_003")); + } + @Test void testMissingRequirementsFile() throws IOException { // Setup directories diff --git a/tests/fixtures/test_project/README.md b/tests/fixtures/test_project/README.md index fe82975..6e4ef5f 100644 --- a/tests/fixtures/test_project/README.md +++ b/tests/fixtures/test_project/README.md @@ -2,10 +2,21 @@ Minimal test project for manual validation of `reqstool-java-gradle-plugin`. +## Source sets + +| Source set | Directory | Annotates | +|---|---|---| +| `main` | `src/main/java` | `@Requirements` on `Hello.hello()` → `REQ_001` | +| `test` | `src/test/java` | `@SVCs` on `HelloTest.testHello()` → `SVC_001` | +| `integrationTest` | `src/integration-test/java` | `@SVCs` on `HelloIT.testHelloIntegration()` → `SVC_002` | + +The `integrationTest` suite exercises multi-source-set annotation merging: both +`SVC_001` and `SVC_002` must appear in the combined `annotations.yml`. + ## Prerequisites - Java 21+ -- Gradle 8+ (or use the repo's `gradlew` wrapper if available) +- Gradle 9+ (or use the repo's `gradlew` wrapper if available) - `reqstool` CLI: `pip install reqstool` ## Validation @@ -18,20 +29,25 @@ Run all commands from `tests/fixtures/test_project/`. gradle build ``` -This runs compilation (triggering the APT annotation processor), tests, and -`assembleRequirements` in one step. +This runs compilation (triggering the APT annotation processor for all source +sets), unit tests, integration tests, and `assembleRequirements` in one step. Expected output: ``` > Task :compileJava Note: Processing annotations: [io.github.reqstool.annotations.Requirements] -Note: Writing Requirements Annotations data to: build/.../annotations.yml +Note: Writing Requirements Annotations data to: build/.../main/resources/annotations.yml > Task :compileTestJava Note: Processing annotations: [io.github.reqstool.annotations.SVCs] -Note: Writing Requirements Annotations data to: build/.../annotations.yml +Note: Writing Requirements Annotations data to: build/.../test/resources/annotations.yml + +> Task :compileIntegrationTestJava +Note: Processing annotations: [io.github.reqstool.annotations.SVCs] +Note: Writing Requirements Annotations data to: build/.../integrationTest/resources/annotations.yml > Task :test +> Task :integrationTest > Task :assembleRequirements BUILD SUCCESSFUL ``` @@ -51,8 +67,16 @@ Expected entries in the zip: - `mypackage-0.1.0-reqstool/software_verification_cases.yml` - `mypackage-0.1.0-reqstool/annotations.yml` - `mypackage-0.1.0-reqstool/test_results/TEST-io.github.reqstool.example.HelloTest.xml` +- `mypackage-0.1.0-reqstool/test_results/TEST-io.github.reqstool.example.HelloIT.xml` - `mypackage-0.1.0-reqstool/reqstool_config.yml` +The combined `annotations.yml` must contain both SVCs: + +```bash +grep "SVC_" build/reqstool/annotations.yml +# expected: SVC_001 and SVC_002 +``` + ### 3 — Run reqstool status The zip is self-contained (test results included), so just extract and run: @@ -62,4 +86,5 @@ unzip -o build/reqstool/mypackage-0.1.0-reqstool.zip -d /tmp/mypackage-reqstool reqstool status local -p /tmp/mypackage-reqstool/mypackage-0.1.0-reqstool ``` -Expected: all green — `REQ_001` implemented, `T1 P1`, no missing tests or SVCs. +Expected: all green — `REQ_001` implemented, `SVC_001` and `SVC_002` both +covered, no missing tests or SVCs. diff --git a/tests/fixtures/test_project/build.gradle b/tests/fixtures/test_project/build.gradle index c56e99b..bbe5411 100644 --- a/tests/fixtures/test_project/build.gradle +++ b/tests/fixtures/test_project/build.gradle @@ -12,22 +12,57 @@ java { } repositories { + mavenLocal() mavenCentral() } dependencies { implementation 'io.github.reqstool:reqstool-java-annotations:1.0.0' annotationProcessor 'io.github.reqstool:reqstool-java-annotations:1.0.0' + testCompileOnly 'io.github.reqstool:reqstool-java-annotations:1.0.0' + testAnnotationProcessor 'io.github.reqstool:reqstool-java-annotations:1.0.0' testImplementation platform('org.junit:junit-bom:6.0.3') testImplementation 'org.junit.jupiter:junit-jupiter-api' - testAnnotationProcessor 'io.github.reqstool:reqstool-java-annotations:1.0.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } -test { - useJUnitPlatform() +testing { + suites { + test { + useJUnitJupiter() + } + + integrationTest(JvmTestSuite) { + sources { + java { + srcDirs = ['src/integration-test/java'] + } + } + dependencies { + implementation project() + } + targets { + all { + testTask.configure { + shouldRunAfter(test) + } + } + } + } + } +} + +configurations { + integrationTestImplementation.extendsFrom implementation, testImplementation + integrationTestCompileOnly.extendsFrom compileOnly, testCompileOnly + integrationTestAnnotationProcessor.extendsFrom annotationProcessor, testAnnotationProcessor + integrationTestRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly +} + +tasks.named('check') { + dependsOn(testing.suites.integrationTest) } requirementsTool { @@ -40,5 +75,5 @@ tasks.named('build') { } tasks.named('assembleRequirements') { - dependsOn tasks.named('test') + dependsOn test, testing.suites.integrationTest } diff --git a/tests/fixtures/test_project/docs/reqstool/software_verification_cases.yml b/tests/fixtures/test_project/docs/reqstool/software_verification_cases.yml index c2da77f..7954524 100644 --- a/tests/fixtures/test_project/docs/reqstool/software_verification_cases.yml +++ b/tests/fixtures/test_project/docs/reqstool/software_verification_cases.yml @@ -6,3 +6,8 @@ cases: title: "Hello method returns hello" verification: automated-test revision: "0.1.0" + - id: SVC_002 + requirement_ids: ["REQ_001"] + title: "Hello method returns hello (integration)" + verification: automated-test + revision: "0.1.0" diff --git a/tests/fixtures/test_project/settings.gradle b/tests/fixtures/test_project/settings.gradle index 93c2cdf..ee72449 100644 --- a/tests/fixtures/test_project/settings.gradle +++ b/tests/fixtures/test_project/settings.gradle @@ -1 +1,8 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} + rootProject.name = 'mypackage' diff --git a/tests/fixtures/test_project/src/integration-test/java/io/github/reqstool/example/HelloIT.java b/tests/fixtures/test_project/src/integration-test/java/io/github/reqstool/example/HelloIT.java new file mode 100644 index 0000000..c3f5099 --- /dev/null +++ b/tests/fixtures/test_project/src/integration-test/java/io/github/reqstool/example/HelloIT.java @@ -0,0 +1,17 @@ +package io.github.reqstool.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import io.github.reqstool.annotations.SVCs; + +class HelloIT { + + @Test + @SVCs("SVC_002") + void testHelloIntegration() { + assertEquals("hello", new Hello().hello()); + } + +}