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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +19,7 @@ public class RequirementsToolExtension {

private final RegularFileProperty requirementsAnnotationsFile;

private final RegularFileProperty svcsAnnotationsFile;
private final ConfigurableFileCollection svcsAnnotationsFiles;

private final RegularFileProperty outputDirectory;

Expand All @@ -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);
Expand All @@ -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())));
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
56 changes: 44 additions & 12 deletions src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand All @@ -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))) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
37 changes: 31 additions & 6 deletions tests/fixtures/test_project/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand All @@ -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:
Expand All @@ -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.
Loading
Loading