Skip to content

Commit 043dfda

Browse files
authored
feat(reqstool): dogfood OpenSpec ↔ reqstool traceability (#131)
* feat(reqstool): dogfood OpenSpec ↔ reqstool traceability 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 <jimisola@jimisola.com> * 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 <jimisola@jimisola.com> * 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 <jimisola@jimisola.com> --------- Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent ee7e527 commit 043dfda

15 files changed

Lines changed: 505 additions & 8 deletions

File tree

.github/workflows/build.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ on:
1111
- reopened
1212
- synchronize
1313

14+
permissions:
15+
contents: read
16+
1417
jobs:
1518
linting:
1619
name: Reuse linting job
@@ -19,6 +22,10 @@ jobs:
1922
build:
2023
needs: linting
2124
runs-on: ubuntu-latest
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
reqstool-source: [pypi, main]
2229
steps:
2330
- name: Check out source repository
2431
uses: actions/checkout@v7
@@ -45,9 +52,25 @@ jobs:
4552
run: poetry run pytest --junitxml=build/junit.xml --cov --cov-report=xml:build/coverage.xml
4653
- name: Build project
4754
run: poetry build
55+
- name: Install reqstool
56+
uses: reqstool/.github/.github/actions/install-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
57+
with:
58+
reqstool-source: ${{ matrix.reqstool-source }}
59+
- name: Validate reqstool spec completeness
60+
# not yet available in the latest PyPI release
61+
if: matrix.reqstool-source == 'main'
62+
uses: reqstool/.github/.github/actions/validate-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
63+
- name: Run reqstool status
64+
uses: reqstool/.github/.github/actions/reqstool-status@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
65+
with:
66+
fail-if-incomplete: "true"
4867
# Upload artifacts for later use
4968
- name: Upload Artifacts
69+
if: matrix.reqstool-source == 'pypi'
5070
uses: actions/upload-artifact@v7
5171
with:
5272
name: dist
5373
path: dist/
74+
75+
validate-openspec:
76+
uses: reqstool/.github/.github/workflows/common-validate-openspec.yml@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22

.mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"reqstool": {
4+
"command": "reqstool",
5+
"args": ["mcp"]
6+
}
7+
}
8+
}

.reqstool-ai.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# reqstool-ai configuration
2+
#
3+
# This file tells reqstool-ai skills where to find your reqstool files
4+
# and how to generate IDs for new requirements and SVCs.
5+
#
6+
# Place this file at: .reqstool-ai.yaml (project root)
7+
8+
# Project URN — matches the urn in your reqstool YAML files
9+
urn: reqstool-python-poetry-plugin
10+
11+
# Revision string for new requirements and SVCs
12+
revision: "0.1.0"
13+
14+
# System-level reqstool directory (contains the SSOT requirements and SVCs)
15+
system:
16+
path: docs/reqstool
17+
18+
# Subproject modules — each module imports a subset of requirements/SVCs via filters
19+
#
20+
# Required fields per module:
21+
# path — path to the module's reqstool directory (contains filter files)
22+
# req_prefix — prefix for requirement IDs belonging to this module (e.g., CORE_)
23+
# svc_prefix — prefix for SVC IDs belonging to this module (e.g., SVC_CORE_)
24+
#
25+
# Add as many modules as your project has. The module name (key) is used in
26+
# commands like `/reqstool:status core` and `/reqstool:add-req core`.
27+
modules:
28+
# Matches the existing POETRY_PLUGIN_NNN / SVC_POETRY_PLUGIN_NNN ID convention.
29+
plugin:
30+
path: docs/reqstool
31+
req_prefix: "POETRY_PLUGIN_"
32+
svc_prefix: "SVC_POETRY_PLUGIN_"

docs/reqstool/requirements.yml

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,55 @@
33
metadata:
44
urn: reqstool-python-poetry-plugin
55
variant: microservice
6-
title: Reqstool client
6+
title: Reqstool Python Poetry Plugin
77
url: https://github.com/reqstool/reqstool-python-poetry-plugin
88

99
requirements:
1010
- id: POETRY_PLUGIN_001
11-
title: Support poetry build system for use with reqstool
11+
title: Bundle reqstool dataset and annotations into the built sdist
1212
significance: shall
13-
description: Support reqstool to be used with poetry, i.e. creating a sdist tarball with reqstool files.
13+
description: >-
14+
The plugin shall ensure that the project's reqstool dataset
15+
(requirements.yml, software_verification_cases.yml,
16+
manual_verification_results.yml, when present), the generated
17+
annotations.yml, and a generated reqstool_config.yml are all present in
18+
the sdist tarball produced by `poetry build`.
1419
categories: [functional-suitability]
15-
revision: 0.0.1
20+
revision: "0.1.0"
21+
- id: POETRY_PLUGIN_002
22+
title: Generate annotations.yml from reqstool decorators during build
23+
significance: shall
24+
description: >-
25+
On `poetry build`, the plugin shall process the configured source
26+
directories for `@Requirements`/`@SVCs` decorators and write the
27+
result to annotations.yml in the configured output directory.
28+
categories: [functional-suitability]
29+
revision: "0.1.0"
30+
- id: POETRY_PLUGIN_003
31+
title: Register reqstool dataset and output directories for packaging
32+
significance: shall
33+
description: >-
34+
On `poetry install`, the plugin shall add the generated
35+
reqstool_config.yml and the configured dataset/output directories to
36+
`[tool.poetry.include]` in pyproject.toml, so they are included when
37+
the project is later built.
38+
categories: [functional-suitability]
39+
revision: "0.1.0"
40+
- id: POETRY_PLUGIN_004
41+
title: Remove generated reqstool_config.yml from the project root after build
42+
significance: shall
43+
description: >-
44+
After `poetry build` terminates, the plugin shall delete the
45+
reqstool_config.yml it generated in the project root, so it does not
46+
linger as an untracked file in the working tree.
47+
categories: [functional-suitability, maintainability]
48+
revision: "0.1.0"
49+
- id: POETRY_PLUGIN_005
50+
title: Clean up excess blank lines introduced into pyproject.toml after install
51+
significance: should
52+
description: >-
53+
After `poetry install`, the plugin shall collapse any run of three or
54+
more consecutive blank lines in pyproject.toml (introduced by the
55+
include-registration step) down to a single blank line.
56+
categories: [maintainability]
57+
revision: "0.1.0"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/software_verification_cases.schema.json
2+
3+
cases:
4+
- id: SVC_POETRY_PLUGIN_001
5+
requirement_ids: ["POETRY_PLUGIN_001"]
6+
title: "Verify the built sdist contains the reqstool dataset, annotations, and config"
7+
verification: automated-test
8+
revision: "0.1.0"
9+
10+
- id: SVC_POETRY_PLUGIN_002
11+
requirement_ids: ["POETRY_PLUGIN_002"]
12+
title: "Verify annotations.yml is generated from decorated source and bundled"
13+
verification: automated-test
14+
revision: "0.1.0"
15+
16+
- id: SVC_POETRY_PLUGIN_003
17+
requirement_ids: ["POETRY_PLUGIN_003"]
18+
title: "Verify pyproject.toml's [tool.poetry.include] is updated with reqstool paths"
19+
verification: automated-test
20+
revision: "0.1.0"
21+
22+
- id: SVC_POETRY_PLUGIN_004
23+
requirement_ids: ["POETRY_PLUGIN_004"]
24+
title: "Verify reqstool_config.yml is removed from the project root after build"
25+
verification: automated-test
26+
revision: "0.1.0"
27+
28+
- id: SVC_POETRY_PLUGIN_005
29+
requirement_ids: ["POETRY_PLUGIN_005"]
30+
title: "Verify excess blank lines are stripped from pyproject.toml after install"
31+
verification: automated-test
32+
revision: "0.1.0"

openspec/openspecui.hooks.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// @reqstool-openspec-hooks: 0.1.1
2+
import { spawn, ChildProcess } from "child_process";
3+
import type { OnReadDocumentHookV1 } from "openspecui/hooks";
4+
5+
// Minimal MCP client over stdio (JSON-RPC 2.0, newline-delimited).
6+
// Uses only Node.js built-ins — no npm packages required.
7+
class McpStdioClient {
8+
private proc: ChildProcess;
9+
private buf = "";
10+
private pending = new Map<
11+
number,
12+
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
13+
>();
14+
private id = 1;
15+
readonly ready: Promise<void>;
16+
17+
constructor(cwd: string) {
18+
this.proc = spawn("reqstool", ["mcp"], {
19+
cwd,
20+
stdio: ["pipe", "pipe", "pipe"],
21+
});
22+
this.proc.stdout!.on("data", (chunk: Buffer) => {
23+
this.buf += chunk.toString();
24+
let nl: number;
25+
while ((nl = this.buf.indexOf("\n")) !== -1) {
26+
const line = this.buf.slice(0, nl).trim();
27+
this.buf = this.buf.slice(nl + 1);
28+
if (line) this.handle(line);
29+
}
30+
});
31+
this.ready = this.init();
32+
}
33+
34+
private handle(line: string) {
35+
try {
36+
const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } };
37+
if (msg.id !== undefined) {
38+
const p = this.pending.get(msg.id);
39+
if (p) {
40+
this.pending.delete(msg.id);
41+
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
42+
}
43+
}
44+
} catch (e) {
45+
console.warn("[reqstool-openspec] Skipping non-JSON line from reqstool mcp:", e instanceof Error ? e.message : e);
46+
}
47+
}
48+
49+
private send(method: string, params: unknown, expectReply = true): Promise<unknown> {
50+
if (!expectReply) {
51+
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
52+
return Promise.resolve();
53+
}
54+
const id = this.id++;
55+
return new Promise((resolve, reject) => {
56+
this.pending.set(id, { resolve, reject });
57+
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
58+
});
59+
}
60+
61+
private async init(): Promise<void> {
62+
await this.send("initialize", {
63+
protocolVersion: "2024-11-05",
64+
capabilities: { tools: {} },
65+
clientInfo: { name: "openspecui", version: "1.0" },
66+
});
67+
this.send("notifications/initialized", {}, false);
68+
}
69+
70+
async enrich(content: string, preset: string): Promise<string> {
71+
await this.ready;
72+
const result = (await this.send("tools/call", {
73+
name: "enrich_document",
74+
arguments: { content, preset },
75+
})) as { content: { text: string }[] };
76+
return result.content[0].text;
77+
}
78+
79+
close() {
80+
this.proc.stdin?.end();
81+
this.proc.kill();
82+
}
83+
}
84+
85+
let client: McpStdioClient | null = null;
86+
87+
export const onReadDocument: OnReadDocumentHookV1 = async (ctx, read) => {
88+
if (!client) {
89+
client = new McpStdioClient(ctx.projectDir);
90+
ctx.lifecycle.onDispose(() => {
91+
client?.close();
92+
client = null;
93+
});
94+
}
95+
96+
const result = await read();
97+
const preset = `openspec:${ctx.document.kind}`;
98+
99+
try {
100+
const enriched = await client.enrich(result.markdown, preset);
101+
return { ...result, markdown: enriched, sourceLabel: `reqstool ${preset}` };
102+
} catch (e) {
103+
return {
104+
...result,
105+
diagnostics: [{ level: "warning", message: `reqstool enrich failed: ${e}` }],
106+
};
107+
}
108+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Annotation Generation Specification
2+
3+
## Purpose
4+
5+
Requirement and SVC content is owned by reqstool (single source of truth). This spec references
6+
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
7+
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.
8+
9+
## Requirements
10+
11+
### Requirement: POETRY_PLUGIN_002
12+
The system SHALL implement POETRY_PLUGIN_002.
13+
14+
#### Scenario: SVC_POETRY_PLUGIN_002
15+
The system SHALL pass SVC_POETRY_PLUGIN_002.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Build Cleanup Specification
2+
3+
## Purpose
4+
5+
Requirement and SVC content is owned by reqstool (single source of truth). This spec references
6+
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
7+
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.
8+
9+
## Requirements
10+
11+
### Requirement: POETRY_PLUGIN_004
12+
The system SHALL implement POETRY_PLUGIN_004.
13+
14+
#### Scenario: SVC_POETRY_PLUGIN_004
15+
The system SHALL pass SVC_POETRY_PLUGIN_004.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Install Cleanup Specification
2+
3+
## Purpose
4+
5+
Requirement and SVC content is owned by reqstool (single source of truth). This spec references
6+
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
7+
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.
8+
9+
## Requirements
10+
11+
### Requirement: POETRY_PLUGIN_005
12+
The system SHALL implement POETRY_PLUGIN_005.
13+
14+
#### Scenario: SVC_POETRY_PLUGIN_005
15+
The system SHALL pass SVC_POETRY_PLUGIN_005.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Sdist Bundling Specification
2+
3+
## Purpose
4+
5+
Requirement and SVC content is owned by reqstool (single source of truth). This spec references
6+
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
7+
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.
8+
9+
## Requirements
10+
11+
### Requirement: POETRY_PLUGIN_001
12+
The system SHALL implement POETRY_PLUGIN_001.
13+
14+
#### Scenario: SVC_POETRY_PLUGIN_001
15+
The system SHALL pass SVC_POETRY_PLUGIN_001.

0 commit comments

Comments
 (0)