From b006558c41ac51a7cafc2d8b637b63715e12bb87 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 22 Jun 2026 09:09:48 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(reqstool):=20dogfood=20OpenSpec=20?= =?UTF-8?q?=E2=86=94=20reqstool=20traceability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the self-referential pattern from reqstool-client#407 and reqstool-demo#104 to this repo: add thin OpenSpec capability specs, derive requirements.yml/software_verification_cases.yml as the SSOT (revision 0.1.0), re-annotate plugin source with @Requirements and tests with @SVCs, and wire CI to validate both via the shared reqstool/.github composite actions and reusable openspec workflow. Also narrows tool.reqstool.sources to exclude tests/fixtures, which was leaking the fixture project's own REQ_001/SVC_001 annotations into this repo's traceability data. Signed-off-by: Jimisola Laursen --- .github/workflows/build.yml | 22 ++++ .mcp.json | 8 ++ .reqstool-ai.yaml | 32 ++++++ docs/reqstool/requirements.yml | 50 +++++++- docs/reqstool/software_verification_cases.yml | 32 ++++++ openspec/openspecui.hooks.ts | 108 ++++++++++++++++++ openspec/specs/annotation-generation/spec.md | 15 +++ openspec/specs/build-cleanup/spec.md | 15 +++ openspec/specs/install-cleanup/spec.md | 15 +++ openspec/specs/sdist-bundling/spec.md | 15 +++ .../specs/sdist-include-registration/spec.md | 15 +++ pyproject.toml | 4 +- src/reqstool_python_poetry_plugin/plugin.py | 6 + .../test_build_e2e.py | 2 + .../test_plugin.py | 86 +++++++++++++- 15 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 .mcp.json create mode 100644 .reqstool-ai.yaml create mode 100644 docs/reqstool/software_verification_cases.yml create mode 100644 openspec/openspecui.hooks.ts create mode 100644 openspec/specs/annotation-generation/spec.md create mode 100644 openspec/specs/build-cleanup/spec.md create mode 100644 openspec/specs/install-cleanup/spec.md create mode 100644 openspec/specs/sdist-bundling/spec.md create mode 100644 openspec/specs/sdist-include-registration/spec.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83abcde..2b27436 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,9 @@ on: - reopened - synchronize +permissions: + contents: read + jobs: linting: name: Reuse linting job @@ -19,6 +22,10 @@ jobs: build: needs: linting runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reqstool-source: [pypi, main] steps: - name: Check out source repository uses: actions/checkout@v6 @@ -45,9 +52,24 @@ jobs: run: poetry run pytest --junitxml=build/junit.xml --cov --cov-report=xml:build/coverage.xml - name: Build project run: poetry build + - 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" # Upload artifacts for later use - name: Upload Artifacts uses: actions/upload-artifact@v7 with: name: dist path: dist/ + + validate-openspec: + uses: reqstool/.github/.github/workflows/common-validate-openspec.yml@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a14c1b1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "reqstool": { + "command": "reqstool", + "args": ["mcp"] + } + } +} diff --git a/.reqstool-ai.yaml b/.reqstool-ai.yaml new file mode 100644 index 0000000..8defe31 --- /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-python-poetry-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 POETRY_PLUGIN_NNN / SVC_POETRY_PLUGIN_NNN ID convention. + plugin: + path: docs/reqstool + req_prefix: "POETRY_PLUGIN_" + svc_prefix: "SVC_POETRY_PLUGIN_" diff --git a/docs/reqstool/requirements.yml b/docs/reqstool/requirements.yml index e08a9d6..4e8dc94 100644 --- a/docs/reqstool/requirements.yml +++ b/docs/reqstool/requirements.yml @@ -3,13 +3,55 @@ metadata: urn: reqstool-python-poetry-plugin variant: microservice - title: Reqstool client + title: Reqstool Python Poetry Plugin url: https://github.com/reqstool/reqstool-python-poetry-plugin requirements: - id: POETRY_PLUGIN_001 - title: Support poetry build system for use with reqstool + title: Bundle reqstool dataset and annotations into the built sdist significance: shall - description: Support reqstool to be used with poetry, i.e. creating a sdist tarball with reqstool files. + description: >- + The plugin shall ensure that the project's reqstool dataset + (requirements.yml, software_verification_cases.yml, + manual_verification_results.yml, when present), the generated + annotations.yml, and a generated reqstool_config.yml are all present in + the sdist tarball produced by `poetry build`. categories: [functional-suitability] - revision: 0.0.1 + revision: "0.1.0" + - id: POETRY_PLUGIN_002 + title: Generate annotations.yml from reqstool decorators during build + significance: shall + description: >- + On `poetry build`, the plugin shall process the configured source + directories for `@Requirements`/`@SVCs` decorators and write the + result to annotations.yml in the configured output directory. + categories: [functional-suitability] + revision: "0.1.0" + - id: POETRY_PLUGIN_003 + title: Register reqstool dataset and output directories for packaging + significance: shall + description: >- + On `poetry install`, the plugin shall add the generated + reqstool_config.yml and the configured dataset/output directories to + `[tool.poetry.include]` in pyproject.toml, so they are included when + the project is later built. + categories: [functional-suitability] + revision: "0.1.0" + - id: POETRY_PLUGIN_004 + title: Remove generated reqstool_config.yml from the project root after build + significance: shall + description: >- + After `poetry build` terminates, the plugin shall delete the + reqstool_config.yml it generated in the project root, so it does not + linger as an untracked file in the working tree. + categories: [functional-suitability, maintainability] + revision: "0.1.0" + - id: POETRY_PLUGIN_005 + title: Clean up excess blank lines introduced into pyproject.toml after install + significance: should + description: >- + After `poetry install`, the plugin shall collapse any run of three or + more consecutive blank lines in pyproject.toml (introduced by the + include-registration step) down to a single blank line. + categories: [maintainability] + 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..5250f33 --- /dev/null +++ b/docs/reqstool/software_verification_cases.yml @@ -0,0 +1,32 @@ +# 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_POETRY_PLUGIN_001 + requirement_ids: ["POETRY_PLUGIN_001"] + title: "Verify the built sdist contains the reqstool dataset, annotations, and config" + verification: automated-test + revision: "0.1.0" + + - id: SVC_POETRY_PLUGIN_002 + requirement_ids: ["POETRY_PLUGIN_002"] + title: "Verify annotations.yml is generated from decorated source and bundled" + verification: automated-test + revision: "0.1.0" + + - id: SVC_POETRY_PLUGIN_003 + requirement_ids: ["POETRY_PLUGIN_003"] + title: "Verify pyproject.toml's [tool.poetry.include] is updated with reqstool paths" + verification: automated-test + revision: "0.1.0" + + - id: SVC_POETRY_PLUGIN_004 + requirement_ids: ["POETRY_PLUGIN_004"] + title: "Verify reqstool_config.yml is removed from the project root after build" + verification: automated-test + revision: "0.1.0" + + - id: SVC_POETRY_PLUGIN_005 + requirement_ids: ["POETRY_PLUGIN_005"] + title: "Verify excess blank lines are stripped from pyproject.toml after install" + 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-generation/spec.md b/openspec/specs/annotation-generation/spec.md new file mode 100644 index 0000000..11b6b92 --- /dev/null +++ b/openspec/specs/annotation-generation/spec.md @@ -0,0 +1,15 @@ +# Annotation Generation 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: POETRY_PLUGIN_002 +The system SHALL implement POETRY_PLUGIN_002. + +#### Scenario: SVC_POETRY_PLUGIN_002 +The system SHALL pass SVC_POETRY_PLUGIN_002. diff --git a/openspec/specs/build-cleanup/spec.md b/openspec/specs/build-cleanup/spec.md new file mode 100644 index 0000000..81e6e04 --- /dev/null +++ b/openspec/specs/build-cleanup/spec.md @@ -0,0 +1,15 @@ +# Build Cleanup 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: POETRY_PLUGIN_004 +The system SHALL implement POETRY_PLUGIN_004. + +#### Scenario: SVC_POETRY_PLUGIN_004 +The system SHALL pass SVC_POETRY_PLUGIN_004. diff --git a/openspec/specs/install-cleanup/spec.md b/openspec/specs/install-cleanup/spec.md new file mode 100644 index 0000000..fabf1da --- /dev/null +++ b/openspec/specs/install-cleanup/spec.md @@ -0,0 +1,15 @@ +# Install Cleanup 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: POETRY_PLUGIN_005 +The system SHALL implement POETRY_PLUGIN_005. + +#### Scenario: SVC_POETRY_PLUGIN_005 +The system SHALL pass SVC_POETRY_PLUGIN_005. diff --git a/openspec/specs/sdist-bundling/spec.md b/openspec/specs/sdist-bundling/spec.md new file mode 100644 index 0000000..cb82903 --- /dev/null +++ b/openspec/specs/sdist-bundling/spec.md @@ -0,0 +1,15 @@ +# Sdist Bundling 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: POETRY_PLUGIN_001 +The system SHALL implement POETRY_PLUGIN_001. + +#### Scenario: SVC_POETRY_PLUGIN_001 +The system SHALL pass SVC_POETRY_PLUGIN_001. diff --git a/openspec/specs/sdist-include-registration/spec.md b/openspec/specs/sdist-include-registration/spec.md new file mode 100644 index 0000000..ef7957a --- /dev/null +++ b/openspec/specs/sdist-include-registration/spec.md @@ -0,0 +1,15 @@ +# Sdist Include Registration 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: POETRY_PLUGIN_003 +The system SHALL implement POETRY_PLUGIN_003. + +#### Scenario: SVC_POETRY_PLUGIN_003 +The system SHALL pass SVC_POETRY_PLUGIN_003. diff --git a/pyproject.toml b/pyproject.toml index cb1a501..05e31bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,9 @@ pattern = "^(?P\\d+\\.\\d+\\.\\d+.*)$" format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}.dev{{ distance }}{% endif %}" [tool.reqstool] -sources = ["src", "tests"] +# excludes tests/fixtures: it's a self-contained fixture project with its own +# decorated REQ_001/SVC_001, unrelated to this plugin's own requirements +sources = ["src", "tests/unit", "tests/integration", "tests/e2e"] test_results = ["build/**/junit.xml"] dataset_directory = "docs/reqstool" output_directory = "build/reqstool" diff --git a/src/reqstool_python_poetry_plugin/plugin.py b/src/reqstool_python_poetry_plugin/plugin.py index ed75999..82fe898 100644 --- a/src/reqstool_python_poetry_plugin/plugin.py +++ b/src/reqstool_python_poetry_plugin/plugin.py @@ -12,6 +12,7 @@ from poetry.console.commands.build import BuildCommand from poetry.console.commands.install import InstallCommand from poetry.plugins.application_plugin import ApplicationPlugin +from reqstool_python_decorators.decorators.decorators import Requirements from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor from ruamel.yaml import YAML @@ -60,6 +61,7 @@ def _on_build_terminate(self, event: ConsoleTerminateEvent, event_name: str, dis if isinstance(command, BuildCommand): self._cleanup_post_build() + @Requirements("POETRY_PLUGIN_004") def _cleanup_post_build(self) -> None: """Deletes reqstool_config.yml from project root after build.""" config_file = self.get_reqstool_config_file(self._poetry) @@ -67,6 +69,7 @@ def _cleanup_post_build(self) -> None: config_file.unlink() self._cleo_io.write_line(f"[reqstool] Removed {self.OUTPUT_SDIST_REQSTOOL_CONFIG_YML} from project root") + @Requirements("POETRY_PLUGIN_005") def _cleanup_pyproject_install_after_install(self) -> None: """Strips excess blank lines from pyproject.toml after install.""" pyproject_path = Path(str(self._poetry.package.root_dir)) / "pyproject.toml" @@ -78,6 +81,7 @@ def _cleanup_pyproject_install_after_install(self) -> None: pyproject_path.write_text(cleaned) self._cleo_io.write_line("[reqstool] Cleaned up excess blank lines in pyproject.toml") + @Requirements("POETRY_PLUGIN_003") def _update_sdist_include(self) -> None: """Adds reqstool files to [tool.poetry.include] in pyproject.toml.""" pyproject_path = Path(str(self._poetry.package.root_dir)) / "pyproject.toml" @@ -123,6 +127,7 @@ def _update_sdist_include(self) -> None: def get_reqstool_config_file(self, poetry) -> Path: return Path(str(poetry.package.root_dir)) / self.OUTPUT_SDIST_REQSTOOL_CONFIG_YML + @Requirements("POETRY_PLUGIN_002") def _create_annotations_file(self) -> None: """Generates the annotations.yml file by processing the reqstool decorators.""" sources = ( @@ -141,6 +146,7 @@ def _create_annotations_file(self) -> None: decorator_processor = DecoratorProcessor() decorator_processor.process_decorated_data(path_to_python_files=sources, output_file=str(annotations_file)) + @Requirements("POETRY_PLUGIN_001") def _generate_reqstool_config(self) -> None: """Generates reqstool_config.yml in the project root for inclusion in the sdist.""" dataset_directory: Path = Path( diff --git a/tests/e2e/reqstool_python_poetry_plugin/test_build_e2e.py b/tests/e2e/reqstool_python_poetry_plugin/test_build_e2e.py index c38075c..df9a887 100644 --- a/tests/e2e/reqstool_python_poetry_plugin/test_build_e2e.py +++ b/tests/e2e/reqstool_python_poetry_plugin/test_build_e2e.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from reqstool_python_decorators.decorators.decorators import SVCs FIXTURE_DIR = Path(__file__).parents[2] / "fixtures" / "test_project" @@ -29,6 +30,7 @@ def _plugin_installed() -> bool: @pytest.mark.e2e @pytest.mark.skipif(not shutil.which("poetry"), reason="poetry not on PATH") @pytest.mark.skipif(not _plugin_installed(), reason="reqstool-python-poetry-plugin not installed in poetry") +@SVCs("SVC_POETRY_PLUGIN_001", "SVC_POETRY_PLUGIN_002") def test_poetry_build_sdist_contains_reqstool_artifacts(): """poetry build (sdist) triggers the reqstool plugin and bundles all artifacts.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py index 7940b04..a56c4ca 100644 --- a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py +++ b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py @@ -1,6 +1,86 @@ # Copyright © LFV +from unittest.mock import MagicMock -def test_plugin(): - # Dummy test - pass +import tomlkit +from reqstool_python_decorators.decorators.decorators import SVCs + +from reqstool_python_poetry_plugin.plugin import ReqstoolPlugin + + +def _make_plugin(root_dir, reqstool_config: dict | None = None) -> ReqstoolPlugin: + plugin = ReqstoolPlugin() + poetry = MagicMock() + poetry.package.root_dir = root_dir + poetry.pyproject.data = {"tool": {"reqstool": reqstool_config or {}}} + plugin._poetry = poetry + plugin._cleo_io = MagicMock() + return plugin + + +@SVCs("SVC_POETRY_PLUGIN_003") +def test_update_sdist_include_adds_reqstool_entries(tmp_path): + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text('[tool.poetry]\nname = "x"\n') + + plugin = _make_plugin(tmp_path) + plugin._update_sdist_include() + + data = tomlkit.loads(pyproject_path.read_text()) + includes = list(data["tool"]["poetry"]["include"]) + assert "reqstool_config.yml" in includes + assert "reqstool/**/*" in includes + assert "build/reqstool/**/*" in includes + + +@SVCs("SVC_POETRY_PLUGIN_003") +def test_update_sdist_include_is_idempotent(tmp_path): + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text('[tool.poetry]\nname = "x"\ninclude = ["reqstool_config.yml"]\n') + + plugin = _make_plugin(tmp_path) + plugin._update_sdist_include() + + data = tomlkit.loads(pyproject_path.read_text()) + includes = list(data["tool"]["poetry"]["include"]) + assert includes.count("reqstool_config.yml") == 1 + + +@SVCs("SVC_POETRY_PLUGIN_004") +def test_cleanup_post_build_removes_reqstool_config(tmp_path): + config_file = tmp_path / "reqstool_config.yml" + config_file.write_text("language: python\n") + + plugin = _make_plugin(tmp_path) + plugin._cleanup_post_build() + + assert not config_file.exists() + + +@SVCs("SVC_POETRY_PLUGIN_004") +def test_cleanup_post_build_is_a_noop_when_no_config_file(tmp_path): + plugin = _make_plugin(tmp_path) + plugin._cleanup_post_build() # must not raise + + +@SVCs("SVC_POETRY_PLUGIN_005") +def test_cleanup_pyproject_strips_excess_blank_lines(tmp_path): + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text('[tool.poetry]\nname = "x"\n\n\n\ninclude = ["reqstool_config.yml"]\n') + + plugin = _make_plugin(tmp_path) + plugin._cleanup_pyproject_install_after_install() + + assert "\n\n\n" not in pyproject_path.read_text() + + +@SVCs("SVC_POETRY_PLUGIN_005") +def test_cleanup_pyproject_is_a_noop_without_excess_blank_lines(tmp_path): + pyproject_path = tmp_path / "pyproject.toml" + original = '[tool.poetry]\nname = "x"\n\ninclude = ["reqstool_config.yml"]\n' + pyproject_path.write_text(original) + + plugin = _make_plugin(tmp_path) + plugin._cleanup_pyproject_install_after_install() + + assert pyproject_path.read_text() == original From 01d21a857d3cfff7516aa023eb6ae8506968ff63 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 22 Jun 2026 09:18:27 +0200 Subject: [PATCH 2/3] fix(review): address full-pr-review findings on dogfooding PR Address feedback from /x:full-pr-review on PR #131: - Fix CI: gate the dist artifact upload to the pypi matrix leg only, since uploading the same artifact name twice (pypi + main legs) fails with actions/upload-artifact@v7. - Tighten unit test mocks: spec the poetry/cleo mocks to their real classes so attribute drift fails loudly instead of silently returning a fresh MagicMock. - Strengthen weak assertions: exact-match the cleaned pyproject.toml content instead of just checking for absence of triple newlines, and assert write_line wasn't called in the no-op cleanup case. - Add a custom dataset/output directory test for _update_sdist_include, and a second idempotency pass. - Add unit test coverage for _generate_reqstool_config's previously untested branches: missing requirements.yml raises, optional files are included only when present, and test_results normalizes a single string into a list. Signed-off-by: Jimisola Laursen --- .github/workflows/build.yml | 1 + .../test_plugin.py | 80 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b27436..fc2fcd7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,6 +66,7 @@ jobs: fail-if-incomplete: "true" # Upload artifacts for later use - name: Upload Artifacts + if: matrix.reqstool-source == 'pypi' uses: actions/upload-artifact@v7 with: name: dist diff --git a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py index a56c4ca..e5f2fcc 100644 --- a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py +++ b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py @@ -2,19 +2,24 @@ from unittest.mock import MagicMock +import pytest import tomlkit +from cleo.io.io import IO +from poetry.poetry import Poetry from reqstool_python_decorators.decorators.decorators import SVCs +from ruamel.yaml import YAML from reqstool_python_poetry_plugin.plugin import ReqstoolPlugin def _make_plugin(root_dir, reqstool_config: dict | None = None) -> ReqstoolPlugin: plugin = ReqstoolPlugin() - poetry = MagicMock() + poetry = MagicMock(spec=Poetry) poetry.package.root_dir = root_dir + poetry.package.version = "1.0.0" poetry.pyproject.data = {"tool": {"reqstool": reqstool_config or {}}} plugin._poetry = poetry - plugin._cleo_io = MagicMock() + plugin._cleo_io = MagicMock(spec=IO) return plugin @@ -40,10 +45,29 @@ def test_update_sdist_include_is_idempotent(tmp_path): plugin = _make_plugin(tmp_path) plugin._update_sdist_include() + plugin._update_sdist_include() data = tomlkit.loads(pyproject_path.read_text()) includes = list(data["tool"]["poetry"]["include"]) assert includes.count("reqstool_config.yml") == 1 + assert includes.count("reqstool/**/*") == 1 + assert includes.count("build/reqstool/**/*") == 1 + + +@SVCs("SVC_POETRY_PLUGIN_003") +def test_update_sdist_include_honors_custom_directories(tmp_path): + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text('[tool.poetry]\nname = "x"\n') + + plugin = _make_plugin( + tmp_path, reqstool_config={"dataset_directory": "custom_ds", "output_directory": "custom_out"} + ) + plugin._update_sdist_include() + + data = tomlkit.loads(pyproject_path.read_text()) + includes = list(data["tool"]["poetry"]["include"]) + assert "custom_ds/**/*" in includes + assert "custom_out/**/*" in includes @SVCs("SVC_POETRY_PLUGIN_004") @@ -60,7 +84,9 @@ def test_cleanup_post_build_removes_reqstool_config(tmp_path): @SVCs("SVC_POETRY_PLUGIN_004") def test_cleanup_post_build_is_a_noop_when_no_config_file(tmp_path): plugin = _make_plugin(tmp_path) - plugin._cleanup_post_build() # must not raise + plugin._cleanup_post_build() + + plugin._cleo_io.write_line.assert_not_called() @SVCs("SVC_POETRY_PLUGIN_005") @@ -71,7 +97,7 @@ def test_cleanup_pyproject_strips_excess_blank_lines(tmp_path): plugin = _make_plugin(tmp_path) plugin._cleanup_pyproject_install_after_install() - assert "\n\n\n" not in pyproject_path.read_text() + assert pyproject_path.read_text() == '[tool.poetry]\nname = "x"\n\ninclude = ["reqstool_config.yml"]\n' @SVCs("SVC_POETRY_PLUGIN_005") @@ -84,3 +110,49 @@ def test_cleanup_pyproject_is_a_noop_without_excess_blank_lines(tmp_path): plugin._cleanup_pyproject_install_after_install() assert pyproject_path.read_text() == original + + +@SVCs("SVC_POETRY_PLUGIN_001") +def test_generate_reqstool_config_raises_when_requirements_missing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "reqstool").mkdir() + + plugin = _make_plugin(tmp_path) + + with pytest.raises(RuntimeError, match="requirements.yml"): + plugin._generate_reqstool_config() + + +@SVCs("SVC_POETRY_PLUGIN_001") +def test_generate_reqstool_config_includes_only_existing_optional_files(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + dataset_dir = tmp_path / "reqstool" + dataset_dir.mkdir() + (dataset_dir / "requirements.yml").write_text("requirements: []\n") + (dataset_dir / "software_verification_cases.yml").write_text("software_verification_cases: []\n") + + plugin = _make_plugin(tmp_path) + plugin._generate_reqstool_config() + + output = tmp_path / "reqstool_config.yml" + data = YAML().load(output.read_text()) + resources = data["resources"] + assert "requirements" in resources + assert "software_verification_cases" in resources + assert "manual_verification_results" not in resources + assert "annotations" not in resources + + +@SVCs("SVC_POETRY_PLUGIN_001") +def test_generate_reqstool_config_normalizes_test_results_to_a_list(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + dataset_dir = tmp_path / "reqstool" + dataset_dir.mkdir() + (dataset_dir / "requirements.yml").write_text("requirements: []\n") + + plugin = _make_plugin(tmp_path, reqstool_config={"test_results": "build/junit.xml"}) + plugin._generate_reqstool_config() + + output = tmp_path / "reqstool_config.yml" + data = YAML().load(output.read_text()) + assert data["resources"]["test_results"] == ["build/junit.xml"] From 3a7d9c5409ff6d39638ae9b57816cbe12a99e24e Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Mon, 22 Jun 2026 09:20:42 +0200 Subject: [PATCH 3/3] test(reqstool): cover _create_annotations_file at unit level Address full-pr-review finding #2: SVC_POETRY_PLUGIN_002 was only verified by the e2e test, which only asserts annotations.yml is present in the built tarball, not that it's actually generated from the configured sources/output path. Add a unit test that mocks DecoratorProcessor and asserts _create_annotations_file passes the configured sources and output path through correctly. Signed-off-by: Jimisola Laursen --- .../test_plugin.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py index e5f2fcc..80eb968 100644 --- a/tests/unit/reqstool_python_poetry_plugin/test_plugin.py +++ b/tests/unit/reqstool_python_poetry_plugin/test_plugin.py @@ -1,6 +1,6 @@ # Copyright © LFV -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import tomlkit @@ -112,6 +112,21 @@ def test_cleanup_pyproject_is_a_noop_without_excess_blank_lines(tmp_path): assert pyproject_path.read_text() == original +@SVCs("SVC_POETRY_PLUGIN_002") +def test_create_annotations_file_passes_configured_sources_and_output_path(tmp_path): + plugin = _make_plugin( + tmp_path, reqstool_config={"sources": ["src", "tests/unit"], "output_directory": "build/reqstool"} + ) + + with patch("reqstool_python_poetry_plugin.plugin.DecoratorProcessor") as decorator_processor_cls: + plugin._create_annotations_file() + + decorator_processor_cls.return_value.process_decorated_data.assert_called_once_with( + path_to_python_files=["src", "tests/unit"], + output_file="build/reqstool/annotations.yml", + ) + + @SVCs("SVC_POETRY_PLUGIN_001") def test_generate_reqstool_config_raises_when_requirements_missing(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path)