Skip to content

Commit e9ab576

Browse files
committed
docs: generate additional docs
1 parent 987ba93 commit e9ab576

12 files changed

Lines changed: 789 additions & 14 deletions

.github/workflows/wc-document-generation.yml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,51 @@ jobs:
2929
set -Eeuo pipefail
3030
python docs/support/gherkin-to-sbdl.py test/cpp/features/*.feature
3131
sbdl -m template-fill --template docs/templates/software-requirements-specification.md.j2 output.sbdl > software-requirements-specification.md
32+
- name: Generate C++ test specification and traceability documents
33+
run: |
34+
set -Eeuo pipefail
35+
python docs/support/generate-sbdl.py --config test-specification \
36+
--gherkin test/cpp/features/*.feature \
37+
--bats test/cpp/integration-tests.bats test/base/integration-tests.bats \
38+
--flavor "C++ (cpp)" \
39+
-o cpp-test-specification.sbdl
40+
sbdl -m template-fill --template docs/templates/software-test-specification.md.j2 cpp-test-specification.sbdl > cpp-software-test-specification.md
41+
sbdl -m template-fill --template docs/templates/requirements-traceability-matrix.md.j2 cpp-test-specification.sbdl > cpp-requirements-traceability-matrix.md
42+
- name: Generate Rust test specification and traceability documents
43+
run: |
44+
set -Eeuo pipefail
45+
python docs/support/generate-sbdl.py --config test-specification \
46+
--gherkin test/cpp/features/*.feature \
47+
--bats test/rust/integration-tests.bats test/base/integration-tests.bats \
48+
--flavor Rust \
49+
-o rust-test-specification.sbdl
50+
sbdl -m template-fill --template docs/templates/software-test-specification.md.j2 rust-test-specification.sbdl > rust-software-test-specification.md
51+
sbdl -m template-fill --template docs/templates/requirements-traceability-matrix.md.j2 rust-test-specification.sbdl > rust-requirements-traceability-matrix.md
52+
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
53+
with:
54+
args: >-
55+
--template eisvogel --listings --number-sections
56+
--output software-requirements-specification.pdf software-requirements-specification.md
57+
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
58+
with:
59+
args: >-
60+
--template eisvogel --listings --number-sections
61+
--output cpp-software-test-specification.pdf cpp-software-test-specification.md
62+
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
63+
with:
64+
args: >-
65+
--template eisvogel --listings --number-sections
66+
--output cpp-requirements-traceability-matrix.pdf cpp-requirements-traceability-matrix.md
67+
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
68+
with:
69+
args: >-
70+
--template eisvogel --listings --number-sections
71+
--output rust-software-test-specification.pdf rust-software-test-specification.md
3272
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
3373
with:
34-
args: --template eisvogel --listings --number-sections --output software-requirements-specification.pdf software-requirements-specification.md
74+
args: >-
75+
--template eisvogel --listings --number-sections
76+
--output rust-requirements-traceability-matrix.pdf rust-requirements-traceability-matrix.md
3577
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
3678
with:
3779
name: documents
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python3
2+
"""BATS test file to SBDL converter.
3+
4+
Parses BATS test files and extracts @test definitions along with their
5+
optional `# bats test_tags=` annotations, producing SBDL `test` elements
6+
with traceability to requirements via tag-based mapping.
7+
"""
8+
9+
import re
10+
from dataclasses import dataclass, field
11+
from typing import Dict, List, Optional
12+
13+
try:
14+
import sbdl
15+
16+
def sanitize_identifier(name: str) -> str:
17+
"""Convert a name to a hyphenated slug identifier."""
18+
slug = re.sub(r'[^a-z0-9]+', '-', name.lower())
19+
return slug.strip('-')
20+
21+
def sanitize_description(text: str) -> str:
22+
return sbdl.SBDL_Parser.sanitize(text)
23+
24+
except ImportError:
25+
def sanitize_identifier(name: str) -> str:
26+
"""Convert a name to a hyphenated slug identifier."""
27+
slug = re.sub(r'[^a-z0-9]+', '-', name.lower())
28+
return slug.strip('-')
29+
30+
def sanitize_description(text: str) -> str:
31+
return text.replace('"', '\\"')
32+
33+
34+
@dataclass
35+
class BatsTest:
36+
"""Represents a single BATS test case."""
37+
38+
name: str
39+
identifier: str
40+
tags: List[str] = field(default_factory=list)
41+
file_path: str = ""
42+
line_number: int = 0
43+
44+
def __post_init__(self):
45+
if not self.identifier:
46+
self.identifier = sanitize_identifier(self.name)
47+
48+
49+
class BatsConverter:
50+
"""Converts BATS test files to SBDL test elements."""
51+
52+
# Regex patterns for BATS file parsing
53+
_TAG_PATTERN = re.compile(r"^#\s*bats\s+test_tags\s*=\s*(.+)$", re.IGNORECASE)
54+
_TEST_PATTERN = re.compile(r'^@test\s+"(.+?)"\s*\{', re.MULTILINE)
55+
56+
def __init__(self, requirement_tag_map: Optional[Dict[str, str]] = None):
57+
"""Initialize the converter.
58+
59+
Args:
60+
requirement_tag_map: Optional mapping from tag names to requirement
61+
identifiers (SBDL element IDs). When provided, tests with
62+
matching tags will get a `requirement` relation in SBDL output.
63+
"""
64+
self.requirement_tag_map = requirement_tag_map or {}
65+
66+
@staticmethod
67+
def _extract_flavor(file_path: str) -> str:
68+
"""Extract the flavor prefix from a BATS file path.
69+
70+
Derives a prefix from the parent directory name of the BATS file,
71+
e.g. 'test/cpp/integration-tests.bats' -> 'cpp'.
72+
73+
Args:
74+
file_path: Path to the BATS file.
75+
76+
Returns:
77+
Flavor string derived from the parent directory.
78+
"""
79+
import os
80+
parent = os.path.basename(os.path.dirname(os.path.abspath(file_path)))
81+
return sanitize_identifier(parent) if parent else ""
82+
83+
def extract_from_bats_file(self, file_path: str) -> List[BatsTest]:
84+
"""Extract all test definitions from a BATS file.
85+
86+
Parses `@test "..."` blocks and any preceding `# bats test_tags=...`
87+
comment lines.
88+
89+
Args:
90+
file_path: Path to the .bats file.
91+
92+
Returns:
93+
List of BatsTest objects.
94+
"""
95+
try:
96+
with open(file_path, "r", encoding="utf-8") as f:
97+
lines = f.readlines()
98+
except (IOError, OSError) as e:
99+
print(f"Error reading {file_path}: {e}")
100+
return []
101+
102+
tests: List[BatsTest] = []
103+
pending_tags: List[str] = []
104+
105+
for line_number, line in enumerate(lines, start=1):
106+
stripped = line.strip()
107+
108+
# Check for tag annotation
109+
tag_match = self._TAG_PATTERN.match(stripped)
110+
if tag_match:
111+
tags_str = tag_match.group(1)
112+
pending_tags = [t.strip() for t in tags_str.split(",") if t.strip()]
113+
continue
114+
115+
# Check for test definition
116+
test_match = self._TEST_PATTERN.match(stripped)
117+
if test_match:
118+
test_name = test_match.group(1)
119+
flavor = self._extract_flavor(file_path)
120+
base_id = sanitize_identifier(test_name)
121+
identifier = f"{flavor}-{base_id}" if flavor else base_id
122+
test = BatsTest(
123+
name=test_name,
124+
identifier=identifier,
125+
tags=list(pending_tags),
126+
file_path=file_path,
127+
line_number=line_number,
128+
)
129+
tests.append(test)
130+
pending_tags = []
131+
print(f" Extracted BATS test: {test.identifier}")
132+
continue
133+
134+
# Reset pending tags if we hit a non-comment, non-empty line
135+
# that isn't a test (so tags don't bleed across unrelated lines)
136+
if stripped and not stripped.startswith("#"):
137+
pending_tags = []
138+
139+
return tests
140+
141+
def write_sbdl_output(self, tests: List[BatsTest], output_file: str):
142+
"""Write extracted BATS tests as SBDL test elements.
143+
144+
Args:
145+
tests: List of BatsTest objects.
146+
output_file: Path to write the SBDL output.
147+
"""
148+
with open(output_file, "w", encoding="utf-8") as f:
149+
f.write("#!sbdl\n")
150+
151+
for test in tests:
152+
escaped_desc = sanitize_description(test.name)
153+
identifier = test.identifier
154+
155+
f.write(f'{identifier} is test {{ description is "{escaped_desc}" custom:title is "{escaped_desc}"')
156+
157+
# Add tag property if tags exist
158+
if test.tags:
159+
tag_str = ",".join(test.tags)
160+
f.write(f" tag is {tag_str}")
161+
162+
# Add requirement relation via tag mapping
163+
req_ids = self._resolve_requirement_relations(test)
164+
if req_ids:
165+
f.write(f" requirement is {','.join(req_ids)}")
166+
167+
f.write(" }\n")
168+
169+
def _resolve_requirement_relations(self, test: BatsTest) -> List[str]:
170+
"""Resolve requirement identifiers from test tags using the tag map.
171+
172+
Args:
173+
test: A BatsTest with tags.
174+
175+
Returns:
176+
List of requirement SBDL identifiers.
177+
"""
178+
req_ids = []
179+
for tag in test.tags:
180+
tag_lower = tag.lower()
181+
if tag_lower in self.requirement_tag_map:
182+
entry = self.requirement_tag_map[tag_lower]
183+
# Support both plain string and (identifier, type) tuple entries
184+
req_id = entry[0] if isinstance(entry, tuple) else entry
185+
if req_id not in req_ids:
186+
req_ids.append(req_id)
187+
return req_ids
188+
189+
def _resolve_typed_relations(self, test: BatsTest) -> Dict[str, List[str]]:
190+
"""Resolve typed relations from test tags using the tag map.
191+
192+
Groups resolved identifiers by their SBDL element type, so the
193+
correct relation keyword (e.g. 'aspect', 'requirement') is used
194+
in the SBDL output.
195+
196+
Args:
197+
test: A BatsTest with tags.
198+
199+
Returns:
200+
Dict mapping SBDL type names to lists of identifiers.
201+
"""
202+
relations: Dict[str, List[str]] = {}
203+
for tag in test.tags:
204+
tag_lower = tag.lower()
205+
if tag_lower in self.requirement_tag_map:
206+
entry = self.requirement_tag_map[tag_lower]
207+
if isinstance(entry, tuple):
208+
identifier, elem_type = entry
209+
else:
210+
identifier, elem_type = entry, "requirement"
211+
if elem_type not in relations:
212+
relations[elem_type] = []
213+
if identifier not in relations[elem_type]:
214+
relations[elem_type].append(identifier)
215+
return relations

0 commit comments

Comments
 (0)