diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17bc2c6..1fe2b21 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,9 +10,16 @@ on: - reopened - synchronize +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reqstool-source: [pypi, main] steps: - uses: actions/checkout@v7 - name: Set up Java @@ -22,3 +29,32 @@ jobs: distribution: "temurin" - name: Build run: ./gradlew clean build + - name: Self-apply plugin to assemble dataset zip + # True self-application (publishing to mavenLocal and re-applying the plugin + # to its own sources from a separate project) is not yet wired up for Gradle. + # As an interim measure, merge the annotation processor's own main/test output + # (already produced by `./gradlew build` above) the same way the plugin's + # combineOutput() would, so reqstool status has real, non-fabricated data. + run: | + mkdir -p build/reqstool + { + echo "# yaml-language-server: \$schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/annotations.schema.json" + yq eval-all '. as $item ireduce ({}; . * $item )' \ + build/generated/sources/annotationProcessor/java/main/resources/annotations.yml \ + build/generated/sources/annotationProcessor/java/test/resources/annotations.yml | grep -v "^#" + } > build/reqstool/annotations.yml + - name: Install reqstool + uses: reqstool/.github/.github/actions/install-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + with: + reqstool-source: ${{ matrix.reqstool-source }} + - name: Validate reqstool spec completeness + # not yet available in the latest PyPI release + if: matrix.reqstool-source == 'main' + uses: reqstool/.github/.github/actions/validate-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + - name: Run reqstool status + uses: reqstool/.github/.github/actions/reqstool-status@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 + with: + fail-if-incomplete: "true" + + validate-openspec: + uses: reqstool/.github/.github/workflows/common-validate-openspec.yml@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 diff --git a/.reqstool-ai.yaml b/.reqstool-ai.yaml new file mode 100644 index 0000000..7777f15 --- /dev/null +++ b/.reqstool-ai.yaml @@ -0,0 +1,32 @@ +# reqstool-ai configuration +# +# This file tells reqstool-ai skills where to find your reqstool files +# and how to generate IDs for new requirements and SVCs. +# +# Place this file at: .reqstool-ai.yaml (project root) + +# Project URN — matches the urn in your reqstool YAML files +urn: reqstool-java-gradle-plugin + +# Revision string for new requirements and SVCs +revision: "0.1.0" + +# System-level reqstool directory (contains the SSOT requirements and SVCs) +system: + path: docs/reqstool + +# Subproject modules — each module imports a subset of requirements/SVCs via filters +# +# Required fields per module: +# path — path to the module's reqstool directory (contains filter files) +# req_prefix — prefix for requirement IDs belonging to this module (e.g., CORE_) +# svc_prefix — prefix for SVC IDs belonging to this module (e.g., SVC_CORE_) +# +# Add as many modules as your project has. The module name (key) is used in +# commands like `/reqstool:status core` and `/reqstool:add-req core`. +modules: + # Matches the existing GRADLE_PLUGIN_NNN / SVC_GRADLE_PLUGIN_NNN ID convention. + plugin: + path: docs/reqstool + req_prefix: "GRADLE_PLUGIN_" + svc_prefix: "SVC_GRADLE_PLUGIN_" diff --git a/build.gradle b/build.gradle index 7e91419..3748d5c 100644 --- a/build.gradle +++ b/build.gradle @@ -45,10 +45,16 @@ dependencies { // Jackson for YAML processing implementation 'com.fasterxml.jackson.core:jackson-databind:2.22.0' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.22.0' - + // SnakeYAML for reqstool_config.yml generation implementation 'org.yaml:snakeyaml:2.6' - + + // reqstool dogfooding: @Requirements/@SVCs annotations + processor + implementation 'io.github.reqstool:reqstool-java-annotations:1.0.0' + annotationProcessor 'io.github.reqstool:reqstool-java-annotations:1.0.0' + testImplementation 'io.github.reqstool:reqstool-java-annotations:1.0.0' + testAnnotationProcessor 'io.github.reqstool:reqstool-java-annotations:1.0.0' + // Testing - JUnit BOM for version management testImplementation platform('org.junit:junit-bom:6.1.0') testImplementation 'org.junit.jupiter:junit-jupiter-api' diff --git a/docs/reqstool/reqstool_config.yml b/docs/reqstool/reqstool_config.yml new file mode 100644 index 0000000..f4f00f2 --- /dev/null +++ b/docs/reqstool/reqstool_config.yml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/reqstool_config.schema.json + +language: java +build: gradle +resources: + requirements: requirements.yml + software_verification_cases: software_verification_cases.yml + annotations: ../../build/reqstool/annotations.yml + test_results: + - ../../build/test-results/test/*.xml diff --git a/docs/reqstool/requirements.yml b/docs/reqstool/requirements.yml new file mode 100644 index 0000000..48fc032 --- /dev/null +++ b/docs/reqstool/requirements.yml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/requirements.schema.json +--- +metadata: + urn: reqstool-java-gradle-plugin + variant: microservice + title: Reqstool Java Gradle Plugin + url: https://github.com/reqstool/reqstool-java-gradle-plugin + +requirements: + - id: GRADLE_PLUGIN_001 + title: Annotation combination + significance: shall + description: >- + The plugin shall combine requirement implementation annotations and SVC test + annotations gathered from multiple source sets into a single annotations + dataset, merging test entries for SVC IDs that appear in more than one + source set. + categories: + - functional-suitability + revision: "0.1.0" + + - id: GRADLE_PLUGIN_002 + title: Zip artifact assembly + significance: shall + description: >- + The plugin shall assemble a reqstool dataset ZIP artifact containing the + requirements dataset files, the combined annotations file, matching test + result files, and a generated reqstool_config.yml. + categories: + - functional-suitability + revision: "0.1.0" + + - id: GRADLE_PLUGIN_003 + title: Skip flags + significance: shall + description: >- + The plugin shall support skip flags that bypass task execution entirely + or bypass only ZIP artifact assembly, while still combining annotations. + categories: + - functional-suitability + revision: "0.1.0" diff --git a/docs/reqstool/software_verification_cases.yml b/docs/reqstool/software_verification_cases.yml new file mode 100644 index 0000000..35ff33c --- /dev/null +++ b/docs/reqstool/software_verification_cases.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/software_verification_cases.schema.json + +cases: + - id: SVC_GRADLE_PLUGIN_001 + requirement_ids: ["GRADLE_PLUGIN_001"] + title: "Verify implementation and test annotations are combined and merged across source sets" + verification: automated-test + revision: "0.1.0" + + - id: SVC_GRADLE_PLUGIN_002 + requirement_ids: ["GRADLE_PLUGIN_002"] + title: "Verify the zip artifact is assembled with the mandatory and optional reqstool resources" + verification: automated-test + revision: "0.1.0" + + - id: SVC_GRADLE_PLUGIN_003 + requirement_ids: ["GRADLE_PLUGIN_003"] + title: "Verify skip and skipAssembleZipArtifact each bypass their respective step" + verification: automated-test + revision: "0.1.0" diff --git a/openspec/openspecui.hooks.ts b/openspec/openspecui.hooks.ts new file mode 100644 index 0000000..49a96ee --- /dev/null +++ b/openspec/openspecui.hooks.ts @@ -0,0 +1,108 @@ +// @reqstool-openspec-hooks: 0.1.1 +import { spawn, ChildProcess } from "child_process"; +import type { OnReadDocumentHookV1 } from "openspecui/hooks"; + +// Minimal MCP client over stdio (JSON-RPC 2.0, newline-delimited). +// Uses only Node.js built-ins — no npm packages required. +class McpStdioClient { + private proc: ChildProcess; + private buf = ""; + private pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + private id = 1; + readonly ready: Promise; + + constructor(cwd: string) { + this.proc = spawn("reqstool", ["mcp"], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + this.proc.stdout!.on("data", (chunk: Buffer) => { + this.buf += chunk.toString(); + let nl: number; + while ((nl = this.buf.indexOf("\n")) !== -1) { + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (line) this.handle(line); + } + }); + this.ready = this.init(); + } + + private handle(line: string) { + try { + const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } }; + if (msg.id !== undefined) { + const p = this.pending.get(msg.id); + if (p) { + this.pending.delete(msg.id); + msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result); + } + } + } catch (e) { + console.warn("[reqstool-openspec] Skipping non-JSON line from reqstool mcp:", e instanceof Error ? e.message : e); + } + } + + private send(method: string, params: unknown, expectReply = true): Promise { + if (!expectReply) { + this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + return Promise.resolve(); + } + const id = this.id++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } + + private async init(): Promise { + await this.send("initialize", { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + clientInfo: { name: "openspecui", version: "1.0" }, + }); + this.send("notifications/initialized", {}, false); + } + + async enrich(content: string, preset: string): Promise { + await this.ready; + const result = (await this.send("tools/call", { + name: "enrich_document", + arguments: { content, preset }, + })) as { content: { text: string }[] }; + return result.content[0].text; + } + + close() { + this.proc.stdin?.end(); + this.proc.kill(); + } +} + +let client: McpStdioClient | null = null; + +export const onReadDocument: OnReadDocumentHookV1 = async (ctx, read) => { + if (!client) { + client = new McpStdioClient(ctx.projectDir); + ctx.lifecycle.onDispose(() => { + client?.close(); + client = null; + }); + } + + const result = await read(); + const preset = `openspec:${ctx.document.kind}`; + + try { + const enriched = await client.enrich(result.markdown, preset); + return { ...result, markdown: enriched, sourceLabel: `reqstool ${preset}` }; + } catch (e) { + return { + ...result, + diagnostics: [{ level: "warning", message: `reqstool enrich failed: ${e}` }], + }; + } +}; diff --git a/openspec/specs/annotation-combination/spec.md b/openspec/specs/annotation-combination/spec.md new file mode 100644 index 0000000..b06e750 --- /dev/null +++ b/openspec/specs/annotation-combination/spec.md @@ -0,0 +1,15 @@ +# Annotation Combination Specification + +## Purpose + +Requirement and SVC content is owned by reqstool (single source of truth). This spec references +reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via +`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`. + +## Requirements + +### Requirement: GRADLE_PLUGIN_001 +The system SHALL implement GRADLE_PLUGIN_001. + +#### Scenario: SVC_GRADLE_PLUGIN_001 +The system SHALL pass SVC_GRADLE_PLUGIN_001. diff --git a/openspec/specs/skip-flags/spec.md b/openspec/specs/skip-flags/spec.md new file mode 100644 index 0000000..5d29d3b --- /dev/null +++ b/openspec/specs/skip-flags/spec.md @@ -0,0 +1,15 @@ +# Skip Flags Specification + +## Purpose + +Requirement and SVC content is owned by reqstool (single source of truth). This spec references +reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via +`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`. + +## Requirements + +### Requirement: GRADLE_PLUGIN_003 +The system SHALL implement GRADLE_PLUGIN_003. + +#### Scenario: SVC_GRADLE_PLUGIN_003 +The system SHALL pass SVC_GRADLE_PLUGIN_003. diff --git a/openspec/specs/zip-assembly/spec.md b/openspec/specs/zip-assembly/spec.md new file mode 100644 index 0000000..0dd08fc --- /dev/null +++ b/openspec/specs/zip-assembly/spec.md @@ -0,0 +1,15 @@ +# Zip Assembly Specification + +## Purpose + +Requirement and SVC content is owned by reqstool (single source of truth). This spec references +reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via +`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`. + +## Requirements + +### Requirement: GRADLE_PLUGIN_002 +The system SHALL implement GRADLE_PLUGIN_002. + +#### Scenario: SVC_GRADLE_PLUGIN_002 +The system SHALL pass SVC_GRADLE_PLUGIN_002. diff --git a/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java b/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java index e7668de..db7c10e 100644 --- a/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java +++ b/src/main/java/io/github/reqstool/gradle/RequirementsToolTask.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import io.github.reqstool.annotations.Requirements; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.ConfigurableFileCollection; @@ -263,6 +264,7 @@ public RegularFileProperty getZipFile() { * annotations file, and optionally assembles a ZIP artifact containing requirements, * annotations, and test results. */ + @Requirements({ "GRADLE_PLUGIN_003" }) @TaskAction public void execute() { if (skip.get()) { @@ -334,6 +336,7 @@ else if (reqAnnotFile != null) { * @param testsNode node containing test cases * @return combined requirement annotations node */ + @Requirements({ "GRADLE_PLUGIN_001" }) static JsonNode combineOutput(JsonNode implementationsNode, JsonNode testsNode) { ObjectNode requirementAnnotationsNode = yamlMapper.createObjectNode(); if (!implementationsNode.isEmpty()) { @@ -355,6 +358,7 @@ static JsonNode combineOutput(JsonNode implementationsNode, JsonNode testsNode) * @param target the node to merge into * @param source the node to merge from */ + @Requirements({ "GRADLE_PLUGIN_001" }) static void mergeTestNodes(ObjectNode target, JsonNode source) { if (!source.isObject()) { return; @@ -396,6 +400,7 @@ private void writeCombinedOutputToFile(File outputFile, JsonNode combinedOutputN * results. * @throws IOException if ZIP creation or file reading fails */ + @Requirements({ "GRADLE_PLUGIN_002" }) private void assembleZipArtifact() throws IOException { String zipArtifactFilename = projectName.get() + "-" + projectVersion.get() + "-reqstool.zip"; String topLevelDir = projectName.get() + "-" + projectVersion.get() + "-reqstool"; diff --git a/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java b/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java index bb0173f..40c7ff5 100644 --- a/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java +++ b/src/test/java/io/github/reqstool/gradle/RequirementsToolTaskTest.java @@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.github.reqstool.annotations.SVCs; + class RequirementsToolTaskTest { @TempDir @@ -88,6 +90,7 @@ void testCombineOutput_withTests() throws IOException { assertTrue(reqAnnotations.has("tests")); } + @SVCs("SVC_GRADLE_PLUGIN_001") @Test void testCombineOutput_withBoth() throws IOException { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); @@ -128,6 +131,7 @@ void testTaskConfiguration() { assertFalse(task.getSkipAssembleZipArtifact().get()); } + @SVCs("SVC_GRADLE_PLUGIN_003") @Test void testSkipExecution() { task.getSkip().set(true); @@ -142,6 +146,7 @@ void testSkipExecution() { assertDoesNotThrow(() -> task.execute()); } + @SVCs("SVC_GRADLE_PLUGIN_001") @Test void testMergeTestNodes_mergesTwoFiles() throws IOException { String yaml1 = "requirement_annotations:\n tests:\n" @@ -192,6 +197,57 @@ void testDeprecatedSvcsAnnotationsFileSetter() { assertTrue(extension.getSvcsAnnotationsFiles().contains(file)); } + @SVCs("SVC_GRADLE_PLUGIN_002") + @Test + void testAssembleZipArtifactHappyPath() throws IOException { + File outputDir = tempDir.resolve("build/reqstool").toFile(); + File datasetDir = tempDir.resolve("reqstool").toFile(); + datasetDir.mkdirs(); + java.nio.file.Files.writeString(new File(datasetDir, "requirements.yml").toPath(), "requirements: []\n"); + + task.getSkip().set(false); + task.getSkipAssembleZipArtifact().set(false); + task.getProjectName().set("test-project"); + task.getProjectVersion().set("1.0.0"); + task.getProjectBasedir().set(tempDir.toFile()); + task.getOutputDirectory().set(outputDir); + task.getDatasetPath().set(datasetDir); + task.getTestResults().set(java.util.Arrays.asList("build/test-results/**/*.xml")); + File zipFile = new File(outputDir, "test-project-1.0.0-reqstool.zip"); + task.getZipFile().set(zipFile); + + task.execute(); + + assertTrue(zipFile.exists()); + assertTrue(new File(outputDir, RequirementsToolTask.OUTPUT_FILE_ANNOTATIONS_YML_FILE).exists()); + } + + @SVCs("SVC_GRADLE_PLUGIN_003") + @Test + void testSkipAssembleZipArtifactBypassesZipCreation() throws IOException { + File outputDir = tempDir.resolve("build/reqstool").toFile(); + File datasetDir = tempDir.resolve("reqstool").toFile(); + datasetDir.mkdirs(); + java.nio.file.Files.writeString(new File(datasetDir, "requirements.yml").toPath(), "requirements: []\n"); + + task.getSkip().set(false); + task.getSkipAssembleZipArtifact().set(true); + task.getProjectName().set("test-project"); + task.getProjectVersion().set("1.0.0"); + task.getProjectBasedir().set(tempDir.toFile()); + task.getOutputDirectory().set(outputDir); + task.getDatasetPath().set(datasetDir); + task.getTestResults().set(java.util.Arrays.asList("build/test-results/**/*.xml")); + File zipFile = new File(outputDir, "test-project-1.0.0-reqstool.zip"); + task.getZipFile().set(zipFile); + + task.execute(); + + assertFalse(zipFile.exists()); + assertTrue(new File(outputDir, RequirementsToolTask.OUTPUT_FILE_ANNOTATIONS_YML_FILE).exists()); + } + + @SVCs("SVC_GRADLE_PLUGIN_002") @Test void testMissingRequirementsFile() throws IOException { // Setup directories