Skip to content
Draft
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
46 changes: 44 additions & 2 deletions .github/workflows/wc-document-generation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,53 @@ jobs:
- name: Generate SRS document
run: |
set -Eeuo pipefail
python docs/support/gherkin-to-sbdl.py test/cpp/features/*.feature
python docs/support/generate-sbdl.py --config requirements --gherkin test/cpp/features/*.feature
sbdl -m template-fill --template docs/templates/software-requirements-specification.md.j2 output.sbdl > software-requirements-specification.md
- name: Generate C++ test plan and traceability documents
run: |
set -Eeuo pipefail
python docs/support/generate-sbdl.py --config test-specification \
--gherkin test/cpp/features/*.feature \
--bats test/cpp/integration-tests.bats test/base/integration-tests.bats \
--flavor "C++ (cpp)" \
-o cpp-test-plan.sbdl
sbdl -m template-fill --template docs/templates/software-test-plan.md.j2 cpp-test-plan.sbdl > cpp-software-test-plan.md
sbdl -m template-fill --template docs/templates/requirements-traceability-matrix.md.j2 cpp-test-plan.sbdl > cpp-requirements-traceability-matrix.md
- name: Generate Rust test plan and traceability documents
run: |
set -Eeuo pipefail
python docs/support/generate-sbdl.py --config test-specification \
--gherkin test/cpp/features/*.feature \
--bats test/rust/integration-tests.bats test/base/integration-tests.bats \
--flavor Rust \
-o rust-test-plan.sbdl
sbdl -m template-fill --template docs/templates/software-test-plan.md.j2 rust-test-plan.sbdl > rust-software-test-plan.md
sbdl -m template-fill --template docs/templates/requirements-traceability-matrix.md.j2 rust-test-plan.sbdl > rust-requirements-traceability-matrix.md
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
with:
args: >-
--template eisvogel --listings --number-sections
--output software-requirements-specification.pdf software-requirements-specification.md
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
with:
args: >-
--template eisvogel --listings --number-sections
--output cpp-software-test-plan.pdf cpp-software-test-plan.md
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
with:
args: >-
--template eisvogel --listings --number-sections
--output cpp-requirements-traceability-matrix.pdf cpp-requirements-traceability-matrix.md
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
with:
args: >-
--template eisvogel --listings --number-sections
--output rust-software-test-plan.pdf rust-software-test-plan.md
- uses: docker://pandoc/extra:3.7.0@sha256:a703d335fa237f8fc3303329d87e2555dca5187930da38bfa9010fa4e690933a
with:
args: --template eisvogel --listings --number-sections --output software-requirements-specification.pdf software-requirements-specification.md
args: >-
--template eisvogel --listings --number-sections
--output rust-requirements-traceability-matrix.pdf rust-requirements-traceability-matrix.md
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: documents
Expand Down
156 changes: 156 additions & 0 deletions docs/support/bats_sbdl_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""BATS test file to SBDL converter.

Parses BATS test files and extracts @test definitions along with their
optional `# bats test_tags=` annotations, producing SBDL `test` elements
with traceability to requirements via tag-based mapping.
"""

import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional

from gherkin_sbdl_converter import to_slug

try:
import sbdl

def sanitize_description(text: str) -> str:
return sbdl.SBDL_Parser.sanitize(text)

except ImportError:
def sanitize_description(text: str) -> str:
return text.replace('"', '\\"')


@dataclass
class BatsTest:
"""Represents a single BATS test case."""

name: str
identifier: str
tags: List[str] = field(default_factory=list)
file_path: str = ""
line_number: int = 0

def __post_init__(self):
if not self.identifier:
self.identifier = to_slug(self.name)


class BatsConverter:
"""Converts BATS test files to SBDL test elements."""

# Regex patterns for BATS file parsing
_TAG_PATTERN = re.compile(r"^#\s*bats\s+test_tags\s*=\s*(.+)$", re.IGNORECASE)
_TEST_PATTERN = re.compile(r'^@test\s+"(.+?)"\s*\{', re.MULTILINE)

def __init__(self, requirement_tag_map: Optional[Dict[str, str]] = None):
"""Initialize the converter.

Args:
requirement_tag_map: Optional mapping from tag names to requirement
identifiers (SBDL element IDs). When provided, tests with
matching tags will get a `requirement` relation in SBDL output.
"""
self.requirement_tag_map = requirement_tag_map or {}
Comment on lines +48 to +56
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requirement_tag_map is annotated as Optional[Dict[str, str]], but the converter actually supports tuple entries (identifier, type) (see _resolve_requirement_relations / _resolve_typed_relations). The current type hint and docstring are inconsistent with runtime behavior; update the annotation (e.g. to a Union[str, tuple[str, str]] value type) and adjust the docstring accordingly.

Copilot uses AI. Check for mistakes.

def extract_from_bats_file(self, file_path: str, flavor: str = "") -> List[BatsTest]:
"""Extract all test definitions from a BATS file.

Parses `@test "..."` blocks and any preceding `# bats test_tags=...`
comment lines.

Args:
file_path: Path to the .bats file.
flavor: Optional identifier prefix for disambiguation
(e.g. 'cpp', 'rust', 'base').

Returns:
List of BatsTest objects.
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
except (IOError, OSError) as e:
print(f"Error reading {file_path}: {e}")
return []

tests: List[BatsTest] = []
pending_tags: List[str] = []

for line_number, line in enumerate(lines, start=1):
stripped = line.strip()

# Check for tag annotation
tag_match = self._TAG_PATTERN.match(stripped)
if tag_match:
tags_str = tag_match.group(1)
pending_tags = [t.strip() for t in tags_str.split(",") if t.strip()]
continue

# Check for test definition
test_match = self._TEST_PATTERN.match(stripped)
if test_match:
test_name = test_match.group(1)
base_id = to_slug(test_name)
identifier = f"{flavor}-{base_id}" if flavor else base_id
test = BatsTest(
name=test_name,
identifier=identifier,
tags=list(pending_tags),
file_path=file_path,
line_number=line_number,
)
tests.append(test)
pending_tags = []
print(f" Extracted BATS test: {test.identifier}")
continue

# Reset pending tags if we hit a non-comment, non-empty line
# that isn't a test (so tags don't bleed across unrelated lines)
if stripped and not stripped.startswith("#"):
pending_tags = []

return tests

_REQ_TAG_PATTERN = re.compile(r'^REQ-', re.IGNORECASE)

def _add_relation(self, relations: Dict[str, List[str]], elem_type: str, identifier: str):
"""Add a relation entry, creating the type list if needed."""
if elem_type not in relations:
relations[elem_type] = []
if identifier not in relations[elem_type]:
relations[elem_type].append(identifier)

def _resolve_typed_relations(self, test: BatsTest) -> Dict[str, List[str]]:
"""Resolve typed relations from test tags using the tag map.

Tags matching the REQ-* pattern are treated as direct requirement
references. Other tags are resolved through the tag map, which
groups resolved identifiers by their SBDL element type so the
correct relation keyword (e.g. 'aspect', 'requirement') is used
in the SBDL output.

Args:
test: A BatsTest with tags.

Returns:
Dict mapping SBDL type names to lists of identifiers.
"""
relations: Dict[str, List[str]] = {}
for tag in test.tags:
# Direct REQ-* tag: use as requirement identifier directly
if self._REQ_TAG_PATTERN.match(tag):
self._add_relation(relations, "requirement", to_slug(tag))
continue

tag_lower = tag.lower()
if tag_lower in self.requirement_tag_map:
entry = self.requirement_tag_map[tag_lower]
if isinstance(entry, tuple):
identifier, elem_type = entry
else:
identifier, elem_type = entry, "requirement"
self._add_relation(relations, elem_type, identifier)
return relations
159 changes: 159 additions & 0 deletions docs/support/generate-sbdl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Unified converter for generating SBDL from Gherkin feature files and BATS test files.

Supports multiple output configurations:
- requirements: Feature/Rule hierarchy as requirements (for SRS)
- test-specification: Feature→aspect, Rule→requirement, Scenario→test + BATS→test (for test spec & traceability)

Usage examples:
# Generate requirements SBDL (existing behavior)
python generate-sbdl.py --config requirements --gherkin test/cpp/features/*.feature

# Generate test specification SBDL with both Gherkin scenarios and BATS tests
python generate-sbdl.py --config test-specification \\
--gherkin test/cpp/features/*.feature \\
--bats test/cpp/integration-tests.bats test/base/integration-tests.bats
"""

import argparse
import os
import sys

from gherkin_mapping_config import FEATURE_RULE_CONFIG, TEST_SPECIFICATION_CONFIG
from gherkin_sbdl_converter import GherkinConverter, to_slug, write_gherkin_sbdl_elements
from bats_sbdl_converter import BatsConverter


def main():
configs = {
"requirements": FEATURE_RULE_CONFIG,
"test-specification": TEST_SPECIFICATION_CONFIG,
}

parser = argparse.ArgumentParser(
description="Unified Gherkin + BATS to SBDL converter"
)
parser.add_argument(
"--gherkin",
nargs="*",
default=[],
help="Paths to Gherkin feature files",
)
parser.add_argument(
"--bats",
nargs="*",
default=[],
help="Paths to BATS test files",
)
parser.add_argument(
"--output",
"-o",
default="output.sbdl",
help="Output SBDL file",
)
parser.add_argument(
"--config",
choices=configs.keys(),
default="requirements",
help="Conversion configuration preset",
)
parser.add_argument(
"--flavor",
default="",
help="Container flavor name for per-flavor document generation",
)

args = parser.parse_args()

if not args.gherkin and not args.bats:
parser.error("At least one --gherkin or --bats file must be specified")

config = configs[args.config]
gherkin_converter = GherkinConverter(config)
gherkin_elements = []

# Process Gherkin feature files
for feature_path in args.gherkin:
if os.path.isfile(feature_path):
print(f"Processing Gherkin: {feature_path}")
elements = gherkin_converter.extract_from_feature_file(feature_path)
gherkin_elements.extend(elements)
else:
print(f"File not found: {feature_path}", file=sys.stderr)

Comment on lines +75 to +83
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a provided --gherkin / --bats path doesn’t exist (common when a glob doesn’t match), the script only prints an error and continues, ultimately exiting 0 and generating an incomplete SBDL/doc set. This will make CI appear green while silently dropping inputs. Consider tracking missing paths and exiting non-zero (or raising) if any inputs are missing.

Copilot uses AI. Check for mistakes.
# Build requirement tag map from Gherkin elements for BATS traceability.
# Maps lowercased sanitized identifiers and feature names to (identifier, element_type)
# tuples, enabling BATS tests tagged with e.g. "Compatibility" to trace to the
# corresponding Gherkin element with the correct SBDL relation type.
requirement_tag_map = {}
for elem in gherkin_elements:
if elem.metadata and elem.metadata.get("gherkin_type") in ("feature", "rule"):
entry = (elem.identifier, elem.element_type.value)
original = elem.metadata.get("original_name", "")
# Map the original feature/rule name (lowercased) to the SBDL identifier + type
if original:
requirement_tag_map[original.lower()] = entry
# Also map the slugified version for BATS tag matching
slug_original = to_slug(original)
if slug_original:
requirement_tag_map[slug_original] = entry
# Also map the SBDL identifier itself
requirement_tag_map[elem.identifier.lower()] = entry

# Process BATS test files
bats_converter = BatsConverter(requirement_tag_map=requirement_tag_map)
bats_tests = []

for bats_path in args.bats:
if os.path.isfile(bats_path):
print(f"Processing BATS: {bats_path}")
flavor_prefix = to_slug(os.path.basename(os.path.dirname(os.path.abspath(bats_path))))
tests = bats_converter.extract_from_bats_file(bats_path, flavor=flavor_prefix)
bats_tests.extend(tests)
else:
print(f"File not found: {bats_path}", file=sys.stderr)

# Write combined SBDL output
_write_combined_sbdl(gherkin_elements, bats_converter, bats_tests, args.output, args.flavor)

total = len(gherkin_elements) + len(bats_tests)
print(f"Extracted {total} elements ({len(gherkin_elements)} from Gherkin, {len(bats_tests)} from BATS) to {args.output}")


def _write_combined_sbdl(gherkin_elements, bats_converter, bats_tests, output_file, flavor=""):
"""Write a single combined SBDL file from both Gherkin and BATS sources."""
import sbdl as sbdl_lib

with open(output_file, "w", encoding="utf-8") as f:
f.write("#!sbdl\n\n")

# Write flavor marker element for per-flavor document generation
if flavor:
escaped_flavor = sbdl_lib.SBDL_Parser.sanitize(flavor)
f.write(f'document-flavor is definition {{ description is "{escaped_flavor}" custom:title is "{escaped_flavor}" }}\n\n')

f.write("# Elements extracted from Gherkin feature files\n")
write_gherkin_sbdl_elements(f, gherkin_elements)

if bats_tests:
f.write("\n# Elements extracted from BATS integration test files\n")

for test in bats_tests:
escaped_desc = sbdl_lib.SBDL_Parser.sanitize(test.name)
identifier = test.identifier

f.write(f'{identifier} is test {{ description is "{escaped_desc}" custom:title is "{escaped_desc}"')

if test.tags:
tag_str = ",".join(test.tags)
f.write(f" tag is {tag_str}")

resolved = bats_converter._resolve_typed_relations(test)
for rel_type, rel_ids in resolved.items():
f.write(f" {rel_type} is {','.join(rel_ids)}")

f.write(" }\n")


if __name__ == "__main__":
main()
Loading
Loading