From 1d45eab20fd66f272ca96ba88ffb9609772d66d0 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Wed, 20 May 2026 16:20:48 +0200 Subject: [PATCH 1/3] lobster-rst-report add a lobster rst report which can be included in any sphinx build --- BUILD.bazel | 11 + CHANGELOG.md | 14 +- MODULE.bazel.lock | 1 + Makefile | 4 +- bazel/private/lobster_test.bzl | 35 ++ documentation/manual-lobster_rst_report.md | 101 ++++ documentation/user-manual.md | 2 +- lobster-rst-report.py | 25 + lobster.bzl | 3 +- lobster/common/BUILD.bazel | 1 + lobster/common/graphviz_utils.py | 47 ++ lobster/requirements.rsl | 7 +- lobster/tools/core/html_report/html_report.py | 13 +- lobster/tools/core/rst_report/BUILD.bazel | 19 + lobster/tools/core/rst_report/__init__.py | 0 lobster/tools/core/rst_report/_helpers.py | 384 +++++++++++++ lobster/tools/core/rst_report/_renderers.py | 445 +++++++++++++++ lobster/tools/core/rst_report/input_file.trlc | 27 + .../requirements/potential_errors.trlc | 170 ++++++ .../rst_report/requirements/requirements.trlc | 49 ++ .../requirements/rst_report_content.trlc | 29 + .../requirements/test_specifications.trlc | 90 +++ lobster/tools/core/rst_report/rst_report.py | 435 ++++++++++++++ lobster/use_cases.trlc | 46 +- packages/lobster-core/setup.py | 6 +- packages/lobster-monolithic/setup.py | 3 +- requirements_dev.txt | 1 + requirements_lock.txt | 4 + .../projects/sphinx_rst_report/.gitignore | 2 + .../projects/sphinx_rst_report/BUILD.bazel | 18 + .../projects/sphinx_rst_report/Makefile | 21 + .../sphinx_rst_report/_static/custom.css | 15 + .../projects/sphinx_rst_report/conf.py | 24 + .../projects/sphinx_rst_report/index.rst | 19 + .../sphinx_rst_report/test_sphinx_build.py | 147 +++++ tests_system/lobster_rst_report/BUILD.bazel | 32 ++ tests_system/lobster_rst_report/__init__.py | 0 .../lobster_rst_report/data/BUILD.bazel | 7 + .../data/basic_report.lobster | 535 ++++++++++++++++++ ...obster_rst_report_system_test_case_base.py | 18 + .../lobster_rst_report_test_runner.py | 43 ++ .../test_rst_report_input_file.py | 267 +++++++++ tests_unit/lobster_rst_report/BUILD.bazel | 20 + tests_unit/lobster_rst_report/__init__.py | 0 .../test_rst_report_helpers.py | 321 +++++++++++ .../test_rst_report_renderers.py | 286 ++++++++++ 46 files changed, 3715 insertions(+), 32 deletions(-) create mode 100644 documentation/manual-lobster_rst_report.md create mode 100644 lobster-rst-report.py create mode 100644 lobster/common/graphviz_utils.py create mode 100644 lobster/tools/core/rst_report/BUILD.bazel create mode 100644 lobster/tools/core/rst_report/__init__.py create mode 100644 lobster/tools/core/rst_report/_helpers.py create mode 100644 lobster/tools/core/rst_report/_renderers.py create mode 100644 lobster/tools/core/rst_report/input_file.trlc create mode 100644 lobster/tools/core/rst_report/requirements/potential_errors.trlc create mode 100644 lobster/tools/core/rst_report/requirements/requirements.trlc create mode 100644 lobster/tools/core/rst_report/requirements/rst_report_content.trlc create mode 100644 lobster/tools/core/rst_report/requirements/test_specifications.trlc create mode 100644 lobster/tools/core/rst_report/rst_report.py create mode 100644 tests_integration/projects/sphinx_rst_report/.gitignore create mode 100644 tests_integration/projects/sphinx_rst_report/BUILD.bazel create mode 100644 tests_integration/projects/sphinx_rst_report/Makefile create mode 100644 tests_integration/projects/sphinx_rst_report/_static/custom.css create mode 100644 tests_integration/projects/sphinx_rst_report/conf.py create mode 100644 tests_integration/projects/sphinx_rst_report/index.rst create mode 100644 tests_integration/projects/sphinx_rst_report/test_sphinx_build.py create mode 100644 tests_system/lobster_rst_report/BUILD.bazel create mode 100644 tests_system/lobster_rst_report/__init__.py create mode 100644 tests_system/lobster_rst_report/data/BUILD.bazel create mode 100644 tests_system/lobster_rst_report/data/basic_report.lobster create mode 100644 tests_system/lobster_rst_report/lobster_rst_report_system_test_case_base.py create mode 100644 tests_system/lobster_rst_report/lobster_rst_report_test_runner.py create mode 100644 tests_system/lobster_rst_report/test_rst_report_input_file.py create mode 100644 tests_unit/lobster_rst_report/BUILD.bazel create mode 100644 tests_unit/lobster_rst_report/__init__.py create mode 100644 tests_unit/lobster_rst_report/test_rst_report_helpers.py create mode 100644 tests_unit/lobster_rst_report/test_rst_report_renderers.py diff --git a/BUILD.bazel b/BUILD.bazel index 3730007e..65b50120 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -65,6 +65,7 @@ py_library( "lobster-online-report-nogit.py", "lobster-python.py", "lobster-report.py", + "lobster-rst-report.py", "lobster-trlc.py", ], visibility = [ @@ -78,6 +79,7 @@ py_library( "//lobster/tools/core/online_report", "//lobster/tools/core/online_report_nogit", "//lobster/tools/core/report", + "//lobster/tools/core/rst_report", "//lobster/tools/cpp", "//lobster/tools/cpptest", "//lobster/tools/gtest", @@ -143,6 +145,15 @@ py_binary( deps = ["//lobster/tools/core/html_report"], ) +py_binary( + name = "lobster-rst-report", + srcs = ["lobster-rst-report.py"], + visibility = [ + "//visibility:public", + ], + deps = ["//lobster/tools/core/rst_report"], +) + py_binary( name = "lobster-json", srcs = ["lobster-json.py"], diff --git a/CHANGELOG.md b/CHANGELOG.md index c977606f..06a6ae5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ ### 1.0.4-dev -* `trlc bazel dep`: update to trlc==2.0.5 +* `lobster-rst-report`: + - New tool: Generate reStructuredText traceability reports for inclusion + in Sphinx documentation projects. + - Supports single-page (`--out`) and multi-page (`--out-dir`) output modes. + - Requires the `sphinx-design` Sphinx extension for dropdown and grid + rendering, and `sphinx.ext.graphviz` for the tracing policy diagram. + - Includes coverage summary table, tracing policy diagram (Graphviz), + issues list, and detailed item cards with cross-references. + - Codebeamer items are rendered as clickable hyperlinks. + - Bazel integration via `subrule_lobster_rst_report` (opt-in). + +* Refactored `is_dot_available()` into `lobster.common.graphviz_utils` + (shared between `lobster-html-report` and `lobster-rst-report`).* `trlc bazel dep`: update to trlc==2.0.5 * Fixed wrong links in [README](README.md). diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1870de9c..6301a033 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -142,6 +142,7 @@ "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", + "https://bcr.bazel.build/modules/rules_python/1.1.0/MODULE.bazel": "57e01abae22956eb96d891572490d20e07d983e0c065de0b2170cafe5053e788", "https://bcr.bazel.build/modules/rules_python/1.5.1/MODULE.bazel": "acfe65880942d44a69129d4c5c3122d57baaf3edf58ae5a6bd4edea114906bf5", "https://bcr.bazel.build/modules/rules_python/1.5.1/source.json": "aa903e1bcbdfa1580f2b8e2d55100b7c18bc92d779ebb507fec896c75635f7bd", "https://bcr.bazel.build/modules/rules_python_gazelle_plugin/1.5.1/MODULE.bazel": "371440271705f949a1b51ca875c7d00ce9057e540fdbf26311445cc10810974b", diff --git a/Makefile b/Makefile index cb740eb4..45e93362 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ lint-system-tests: style tests_system/lobster_online_report_nogit \ tests_system/lobster_pkg \ tests_system/lobster_report \ + tests_system/lobster_rst_report \ tests_system/lobster_trlc \ tests_system/system_test_case_base.py @@ -90,7 +91,8 @@ packages: clean-packages lobster-json --version && \ lobster-python --version && \ lobster-trlc --version && \ - lobster-pkg --version + lobster-pkg --version && \ + lobster-rst-report --version clang-tidy: cd .. && \ diff --git a/bazel/private/lobster_test.bzl b/bazel/private/lobster_test.bzl index 7265d4b3..06e3faea 100644 --- a/bazel/private/lobster_test.bzl +++ b/bazel/private/lobster_test.bzl @@ -64,6 +64,41 @@ subrule_lobster_html_report = subrule( }, ) +def _lobster_rst_report_subrule_impl(ctx, lobster_report, _lobster_rst_report): + lobster_rst_report = ctx.actions.declare_file("{}_report.rst".format(ctx.label.name)) + + # Compute relative path from the RST output back to the workspace root so + # that file references in the report resolve correctly when included in Sphinx. + package = ctx.label.package + package_depth = len(package.split("/")) if package else 0 + source_root = "/".join([".." for _ in range(package_depth + 1)]) + "/" + + args = ctx.actions.args() + args.add(lobster_report.path) + args.add_all(["--out", lobster_rst_report.path]) + args.add_all(["--source-root", source_root]) + + ctx.actions.run( + executable = _lobster_rst_report, + inputs = [lobster_report], + outputs = [lobster_rst_report], + arguments = [args], + progress_message = "lobster-rst-report {}".format(lobster_rst_report.path), + ) + + return lobster_rst_report + +subrule_lobster_rst_report = subrule( + implementation = _lobster_rst_report_subrule_impl, + attrs = { + "_lobster_rst_report": attr.label( + default = "//:lobster-rst-report", + executable = True, + cfg = "exec", + ), + }, +) + def _lobster_test_impl(ctx): lobster_config_substitutions = {} for input_config in ctx.attr.inputs: diff --git a/documentation/manual-lobster_rst_report.md b/documentation/manual-lobster_rst_report.md new file mode 100644 index 00000000..c265c1f4 --- /dev/null +++ b/documentation/manual-lobster_rst_report.md @@ -0,0 +1,101 @@ +# lobster-rst-report + +Generate reStructuredText (RST) traceability reports from LOBSTER +report files, for inclusion in a Sphinx documentation project. + +## Overview + +`lobster-rst-report` converts a `.lobster` report file into RST that +can be built by Sphinx. The output uses +[sphinx-design](https://sphinx-design.readthedocs.io/) directives +(dropdowns, grids, cards) for a rich presentation and +[sphinx.ext.graphviz](https://www.sphinx-doc.org/en/master/usage/extensions/graphviz.html) +for the tracing policy diagram. + +Two output modes are available: + +* **Single-page** (`--out FILE`) — one RST file containing the entire + report. +* **Multi-page** (`--out-dir DIR`) — an `index.rst` plus one RST file + per tracing level, linked via `toctree` directives. + +## Installation + +`lobster-rst-report` is included in the `bmw-lobster-core` and +`bmw-lobster-monolithic` packages. + +Your Sphinx project additionally requires: + +``` +pip install sphinx-design +``` + +## Usage + +``` +lobster-rst-report [LOBSTER_REPORT] [--out FILE | --out-dir DIR] [--source-root PREFIX] +``` + +| Argument | Description | +|---|---| +| `LOBSTER_REPORT` | Path to the `.lobster` report file (default: `report.lobster`). | +| `--out FILE` | Write a single-page RST report to `FILE` (default: `lobster_report.rst`). | +| `--out-dir DIR` | Write a multi-page RST report to `DIR` (index.rst + one page per level). | +| `--source-root PREFIX` | Prefix prepended to file reference URLs. Use this when the RST output directory differs from the workspace root. | + +`--out` and `--out-dir` are mutually exclusive. + +### Example + +```bash +# Generate a multi-page RST report +lobster-rst-report report.lobster --out-dir docs/traceability + +# Generate a single-page RST report +lobster-rst-report report.lobster --out docs/traceability.rst +``` + +## Sphinx project setup + +Your Sphinx `conf.py` must load the required extensions: + +```python +extensions = ["sphinx_design", "sphinx.ext.graphviz"] +``` + +For multi-page mode, include the generated `index.rst` via a `toctree` +in your main documentation: + +```rst +.. toctree:: + :maxdepth: 2 + :caption: Traceability + + traceability/index +``` + +### Optional: custom CSS + +The generated RST uses the CSS class `lobster-issue-card` for issue +message cards. You can style them in a custom stylesheet: + +```css +.lobster-issue-card .sd-card-body { + background-color: #f8d7da; +} +.lobster-issue-card { + border: 2px solid #dc3545; +} +``` + +## Bazel integration + +The tool is available as a Bazel subrule for use in custom rules: + +```starlark +load("//:lobster.bzl", "subrule_lobster_rst_report") +``` + +The subrule accepts a `.lobster` report file and produces a single-page +`.rst` file. It automatically computes `--source-root` based on the +package depth so that file reference links resolve correctly. diff --git a/documentation/user-manual.md b/documentation/user-manual.md index ad210396..1b0c0ea5 100644 --- a/documentation/user-manual.md +++ b/documentation/user-manual.md @@ -12,7 +12,7 @@ simplify this. The basic setup is as follows: 2. Annotate traces onto your code, your tests and/or your requirements. Please notice each one of the supported languages has their own way to annotate the tracing tags. 3. Extract the requirement traces with the corresponding lobster conversion tools (e.g. `lobster-cpp`). This converts those traces into a *.lobster file (into a "common unified interchange format"). 4. Build report by calling `lobster-report` -5. Render your report by calling the core losbter tools. Usually a local HTML report is desired via `lobster-html-report` or even `lobster-ci-report` for your CI. +5. Render your report by calling the core lobster tools. Usually a local HTML report is desired via `lobster-html-report`, an RST report for Sphinx documentation via `lobster-rst-report`, or even `lobster-ci-report` for your CI. The basic idea is that you have a number of artefacts that you wish to relate to each other; stored in different "databases". Sometimes this diff --git a/lobster-rst-report.py b/lobster-rst-report.py new file mode 100644 index 00000000..8249f975 --- /dev/null +++ b/lobster-rst-report.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# +# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . + +import sys + +from lobster.tools.core.rst_report.rst_report import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lobster.bzl b/lobster.bzl index 1c3bd6c4..56fcabb1 100644 --- a/lobster.bzl +++ b/lobster.bzl @@ -2,7 +2,7 @@ load("//bazel:providers.bzl", _LobsterProvider = "LobsterProvider") load("//bazel/private:gtest_report.bzl", _gtest_report = "gtest_report", _subrule_gtest_report = "subrule_gtest_report") load("//bazel/private:lobster_gtest.bzl", _lobster_gtest = "lobster_gtest", _subrule_lobster_gtest = "subrule_lobster_gtest") load("//bazel/private:lobster_raw.bzl", _lobster_raw = "lobster_raw") -load("//bazel/private:lobster_test.bzl", _lobster_test = "lobster_test", _subrule_lobster_html_report = "subrule_lobster_html_report", _subrule_lobster_report = "subrule_lobster_report") +load("//bazel/private:lobster_test.bzl", _lobster_test = "lobster_test", _subrule_lobster_html_report = "subrule_lobster_html_report", _subrule_lobster_report = "subrule_lobster_report", _subrule_lobster_rst_report = "subrule_lobster_rst_report") load("//bazel/private:lobster_trlc.bzl", _lobster_trlc = "lobster_trlc", _subrule_lobster_trlc = "subrule_lobster_trlc") # Re-export LobsterProvider so it can be loaded from this file @@ -26,5 +26,6 @@ def lobster_gtest(**kwargs): subrule_lobster_trlc = _subrule_lobster_trlc subrule_lobster_gtest = _subrule_lobster_gtest subrule_lobster_html_report = _subrule_lobster_html_report +subrule_lobster_rst_report = _subrule_lobster_rst_report subrule_lobster_report = _subrule_lobster_report subrule_gtest_report = _subrule_gtest_report diff --git a/lobster/common/BUILD.bazel b/lobster/common/BUILD.bazel index 4655d7ee..a881f744 100644 --- a/lobster/common/BUILD.bazel +++ b/lobster/common/BUILD.bazel @@ -23,6 +23,7 @@ py_library( "exceptions.py", "file_collector.py", "file_tag_generator.py", + "graphviz_utils.py", "io.py", "items.py", "level_definition.py", diff --git a/lobster/common/graphviz_utils.py b/lobster/common/graphviz_utils.py new file mode 100644 index 00000000..4a279346 --- /dev/null +++ b/lobster/common/graphviz_utils.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# +# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +"""Shared Graphviz utilities for LOBSTER report tools.""" + +import subprocess +from typing import Optional + + +def is_dot_available(dot: Optional[str] = None) -> bool: + """Return True if the ``dot`` executable (Graphviz) is on PATH. + + Args: + dot: Optional explicit path to the ``dot`` binary. When ``None`` + (default) the system PATH is searched. + + Returns: + ``True`` if Graphviz ``dot`` is available, ``False`` otherwise. + """ + try: + subprocess.run( + [dot if dot else "dot", "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="UTF-8", + check=True, + timeout=5, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired, + subprocess.CalledProcessError): + return False diff --git a/lobster/requirements.rsl b/lobster/requirements.rsl index 1d6d9e40..9fb920c1 100644 --- a/lobster/requirements.rsl +++ b/lobster/requirements.rsl @@ -70,10 +70,11 @@ enum Tools { // tools that consume the output of other LOBSTER tools lobster_online_report lobster_html_report + lobster_rst_report } type UseCase { - description ''' + description ''' The text of the use case. The format shall be : As a < role > I want < description of the UseCase > A use case shall describe the goal that the user wants to achieve by using LOBSTER. @@ -86,7 +87,7 @@ type UseCase { enum Impact_Type { Safety - ''' + ''' The potential error has a safety impact. ''' Financial @@ -102,7 +103,7 @@ type PotentialError { This shall be a description of a potential error that may occur in a tool. ''' String impacts String [1..*] - affects + affects '''List of use cases which the potential error could affect''' UseCase[1..*] impact_type Impact_Type diff --git a/lobster/tools/core/html_report/html_report.py b/lobster/tools/core/html_report/html_report.py index 623486cf..0db730c5 100755 --- a/lobster/tools/core/html_report/html_report.py +++ b/lobster/tools/core/html_report/html_report.py @@ -39,6 +39,7 @@ Requirement, Implementation, Activity) from lobster.common.meta_data_tool_base import MetaDataToolBase +from lobster.common.graphviz_utils import is_dot_available from lobster.tools.core.html_report.html_report_css import CSS from lobster.tools.core.html_report.html_report_js import JAVA_SCRIPT @@ -46,18 +47,6 @@ LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster" -def is_dot_available(dot): - try: - subprocess.run([dot if dot else "dot", "-V"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - encoding="UTF-8", - check=True) - return True - except FileNotFoundError: - return False - - def name_hash(name): hobj = hashlib.md5() hobj.update(name.encode("UTF-8")) diff --git a/lobster/tools/core/rst_report/BUILD.bazel b/lobster/tools/core/rst_report/BUILD.bazel new file mode 100644 index 00000000..e63c305a --- /dev/null +++ b/lobster/tools/core/rst_report/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_python//python:defs.bzl", "py_library") + +# BUILD.bazel +# gazelle:exclude __init__.py + +py_library( + name = "rst_report", + srcs = [ + "_helpers.py", + "_renderers.py", + "rst_report.py", + ], + visibility = [ + "//visibility:public", + ], + deps = [ + "//lobster/common", + ], +) diff --git a/lobster/tools/core/rst_report/__init__.py b/lobster/tools/core/rst_report/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lobster/tools/core/rst_report/_helpers.py b/lobster/tools/core/rst_report/_helpers.py new file mode 100644 index 00000000..f615c0da --- /dev/null +++ b/lobster/tools/core/rst_report/_helpers.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +# +# lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +""" +Primitive helpers for the RST report tool. + +Organised into four static-method classes with focused concerns: + +* :class:`RstUtils` -- RST text escaping and heading generation +* :class:`ItemNaming` -- label, page-name, and kind-string derivation +* :class:`TracingClassifier` -- message classification and status-to-CSS mapping +* :class:`PolicyDiagramBuilder`-- Graphviz DOT generation for the tracing policy +""" + +from typing import Dict, Tuple + +from lobster.common.report import Report +from lobster.common.location import ( + Void_Reference, + File_Reference, + Github_Reference, + Codebeamer_Reference, +) +from lobster.common.items import Item, Requirement, Implementation, Activity + + +class RstUtils: + """Pure RST text-formatting utilities.""" + + @staticmethod + def escape(text: str) -> str: + """Escape characters that carry special meaning in RST inline markup. + + The characters ``\\``, `` ` ``, ``*``, ``_``, and ``|`` are each + prefixed with a backslash so they are treated as literals by Sphinx. + + Args: + text: The plain-text string to escape. + + Returns: + The escaped string, safe for use in any RST inline context. + """ + for ch in ("\\", "`", "*", "_", "|"): + text = text.replace(ch, "\\" + ch) + return text + + @staticmethod + def heading(text: str, char: str, overline: bool = False) -> list: + """Return a list of RST lines that form a section heading. + + The caller is responsible for appending a blank string after the + returned lines to satisfy RST's required blank line. + + Args: + text: The heading text. + char: The underline (and overline, if *overline* is ``True``) + character (e.g. ``"="``, ``"-"``, ``"~"``). + overline: When ``True`` an overline of the same character is + added above the heading text. + + Returns: + A list of 2 lines (underline only) or 3 lines (with overline). + No trailing blank line is included. + """ + line = char * len(text) + if overline: + return [line, text, line] + return [text, line] + + +class ItemNaming: + """Helpers for generating Sphinx labels and human-readable strings for + LOBSTER items.""" + + @staticmethod + def item_label(item: Item) -> str: + """Return the Sphinx cross-reference label for a tracing item. + + The label is stable as long as the item's hash does not change. + + Args: + item: Any LOBSTER :class:`~lobster.common.items.Item`. + + Returns: + A string of the form ``"lobster-item-"``. + """ + return "lobster-item-" + item.tag.hash() + + @staticmethod + def level_label(level_name: str) -> str: + """Return the Sphinx cross-reference label for a level section. + + Args: + level_name: The human-readable level name + (e.g. ``"System Requirements"``). + + Returns: + A slug of the form ``"lobster-level-system-requirements"``. + """ + return "lobster-level-" + level_name.replace(" ", "-").lower() + + @staticmethod + def level_page_name(level_name: str) -> str: + """Return a filesystem-safe page stem (no file extension) for a level. + + All non-alphanumeric characters are replaced by underscores; + consecutive underscores are collapsed; leading and trailing + underscores are stripped. + + Args: + level_name: The human-readable level name. + + Returns: + A lowercase, underscore-separated identifier suitable for use as + a filename stem (e.g. ``"system_requirements"``). + """ + safe = level_name.lower() + for ch in (" ", "-", "/", "\\", "(", ")", ",", "."): + safe = safe.replace(ch, "_") + while "__" in safe: + safe = safe.replace("__", "_") + return safe.strip("_") or "level" + + @staticmethod + def item_kind_str(item: Item) -> str: + """Return a human-readable kind label for a LOBSTER item. + + Combines the item's framework or language with its kind, e.g. + ``"TRLC Requirement"`` or ``"Python Function"``. + + Args: + item: Any LOBSTER :class:`~lobster.common.items.Item`. + + Returns: + A capitalised kind string. + """ + if isinstance(item, Requirement): + return f"{item.framework} {item.kind.capitalize()}" + if isinstance(item, Implementation): + return f"{item.language} {item.kind.capitalize()}" + assert isinstance(item, Activity) + return f"{item.framework} {item.kind.capitalize()}" + + @staticmethod + def location_link(location, source_root: str = "") -> str: + """Convert a LOBSTER location to an RST anonymous hyperlink or plain text. + + Produces a clickable `` `text `__ `` for file, GitHub, and + Codebeamer references, and falls back to escaped plain text for any + other location type. + + Args: + location: A LOBSTER location object. + source_root: Optional URL prefix prepended to plain + :class:`~lobster.common.location.File_Reference` paths. + + Returns: + An RST inline hyperlink string or plain escaped text. + """ + e = RstUtils.escape + + if isinstance(location, Void_Reference): + return "unknown location" + + # lobster-trace: UseCases.Item_GitHub_Source + # lobster-trace: rst_req.RST_Source_Root_Prefix + if isinstance(location, File_Reference): + href = source_root + location.filename if source_root else location.filename + return f"`{e(location.to_string())} <{href}>`__" + + # lobster-trace: UseCases.Item_GitHub_Source + if isinstance(location, Github_Reference): + url = f"{location.gh_root}/blob/{location.commit}/{location.filename}" + if location.line: + url += f"#L{location.line}" + return f"`{e(location.to_string())} <{url}>`__" + + # lobster-trace: UseCases.Show_codebeamer_links + # lobster-trace: rst_req.RST_Clickable_Codebeamer_Item + # lobster-trace: rst_req.RST_Codebeamer_Item_Name + if isinstance(location, Codebeamer_Reference): + url = f"{location.cb_root}/issue/{location.item}" + if location.version: + url += f"?version={location.version}" + return f"`{e(location.to_string())} <{url}>`__" + + return e(str(location)) + + +class TracingClassifier: + """Classify tracing messages and map tracing status to sphinx-design CSS classes.""" + + #: Mapping from :attr:`~lobster.common.items.Tracing_Status` name to + #: sphinx-design card-header CSS classes. + CARD_CLASSES: Dict[str, str] = { + "OK": "sd-bg-success sd-text-white", + "JUSTIFIED": "sd-bg-success sd-text-white", + "PARTIAL": "sd-bg-warning", + "MISSING": "sd-bg-danger sd-text-white", + "ERROR": "sd-bg-danger sd-text-white", + } + + @staticmethod + def categorize(messages: list) -> tuple: + """Split issue messages into downward, upward, and general buckets. + + Inspects each message's text to decide whether it describes a problem + with a downward tracing link (traces *to* another item), an upward + tracing link (derived *from* another item), or something else. + + .. note:: + Classification is based on substring matching against the following + known LOBSTER message patterns: + + * Upward: ``"up reference"``, ``"missing upward"``, + ``"upward tracing"`` + * Downward: ``"down reference"``, ``"missing downward"``, + ``"missing reference to"``, ``"tracing destination"``, + ``"unknown tracing target"``, ``"downward tracing"`` + * General: everything else + + If upstream message wording changes, update both this method and + its tests. + + Args: + messages: A list of raw tracing-issue message strings. + + Returns: + A 3-tuple ``(down_msgs, up_msgs, general_msgs)`` where each + element is a list of message strings. + """ + down, up, general = [], [], [] + for msg in messages: + ml = msg.lower() + if "up reference" in ml or "missing upward" in ml or "upward tracing" in ml: + up.append(msg) + elif ("down reference" in ml or + "missing downward" in ml or + "missing reference to" in ml): + down.append(msg) + elif ("tracing destination" in ml or + "unknown tracing target" in ml or + "downward tracing" in ml): + down.append(msg) + else: + general.append(msg) + return down, up, general + + @staticmethod + def issue_tag(msg: str) -> str: + """Return a concise human-readable label for a tracing issue message. + + Transforms verbose internal messages into short display labels, e.g. + ``"missing reference to Verification Test"`` becomes + ``"no trace to: Verification Test"``. + + Args: + msg: A raw tracing-issue message string. + + Returns: + A short descriptive label, or the original message (RST-escaped) + if no known pattern matches. + """ + ml = msg.lower() + e = RstUtils.escape + if "version" in ml and "expected" in ml: + return "version mismatch" + if "unknown tracing target" in ml: + target = msg.split("unknown tracing target ", 1)[-1] + return f"unknown target: {e(target)}" + if "missing up reference" in ml: + return "no upward trace" + if "missing reference to " in ml: + target = msg.split("missing reference to ", 1)[-1] + return f"no trace to: {e(target)}" + if "missing down reference" in ml: + return "no downward trace" + return e(msg) + + @classmethod + def card_header_class(cls, status_name: str) -> str: + """Return sphinx-design CSS class string for a card header. + + Args: + status_name: The ``name`` attribute of a + :class:`~lobster.common.items.Tracing_Status` member + (e.g. ``"OK"``, ``"MISSING"``). + + Returns: + A space-separated string of sphinx-design CSS classes. + Falls back to ``"sd-bg-secondary sd-text-white"`` for unknown values. + """ + return cls.CARD_CLASSES.get(status_name, "sd-bg-secondary sd-text-white") + + +class PolicyDiagramBuilder: + """Build a Graphviz DOT diagram representing the configured tracing policy. + + Nodes represent tracing levels, coloured by kind: + + * *requirements* -- blue (``#2196F3``) + * *implementation* -- green (``#4CAF50``) + * *activity* -- orange (``#FF9800``) + + Directed edges follow the ``traces`` relationship (level A → level B means + A must be traceable to B). + """ + + #: Fill and font colour pairs keyed by level kind. + KIND_COLORS: Dict[str, Tuple[str, str]] = { + "requirements": ("#2196F3", "white"), + "implementation": ("#4CAF50", "white"), + "activity": ("#FF9800", "white"), + } + + @staticmethod + def dot_escape(text: str) -> str: + """Escape *text* for safe embedding inside a DOT double-quoted string. + + Args: + text: Arbitrary string to use in a DOT node label or attribute. + + Returns: + The text with backslashes and double-quotes escaped. + """ + return text.replace("\\", "\\\\").replace('"', '\\"') + + @classmethod + def build(cls, report: Report, indent: int = 0) -> list: + """Return RST lines for a ``.. graphviz::`` tracing-policy diagram. + + Args: + report: The loaded LOBSTER report whose ``config`` provides level + names, kinds, and tracing relationships. + indent: Number of leading spaces to prepend to each line. Use + a non-zero value when embedding inside nested RST directives + such as ``.. grid-item::``. + + Returns: + A list of RST lines ending with a blank string. + """ + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram + indent_str = " " * indent + nested_indent = indent_str + " " + out = [] + out.append(f"{indent_str}.. graphviz::") + out.append("") + out.append(f"{nested_indent}digraph tracing_policy {{") + out.append(f"{nested_indent} rankdir=TB;") + out.append( + f"{nested_indent} node [shape=box, style=filled, " + f'fontname="Helvetica", margin="0.3,0.1"];' + ) + out.append(f"{nested_indent} edge [arrowhead=open];") + out.append("") + for level_name, level in report.config.items(): + fill, font = cls.KIND_COLORS.get(level.kind, ("#9E9E9E", "white")) + safe = cls.dot_escape(level_name) + out.append( + f'{nested_indent} "{safe}" [fillcolor="{fill}", fontcolor="{font}"];' + ) + for level_name, level in report.config.items(): + for trace_target in level.traces: + src = cls.dot_escape(level_name) + dst = cls.dot_escape(trace_target) + out.append(f'{nested_indent} "{src}" -> "{dst}";') + out.append(f"{nested_indent}}}") + out.append("") + return out diff --git a/lobster/tools/core/rst_report/_renderers.py b/lobster/tools/core/rst_report/_renderers.py new file mode 100644 index 00000000..7fd4d5b6 --- /dev/null +++ b/lobster/tools/core/rst_report/_renderers.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +# +# lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +""" +RST block builder classes for the RST report tool. + +Each class accepts LOBSTER report data and returns RST lines via a ``build()`` +method. Lines can be joined with ``"\\n".join(lines)`` to form valid RST. + +Classes +------- +* :class:`ItemCardBuilder` -- one item as a sphinx-design card +* :class:`LevelSectionBuilder` -- all items for one level (Issues + OK Items) +* :class:`CoverageGridBuilder` -- coverage table + graphviz policy diagram +* :class:`IssuesListBuilder` -- index-page issue summary list + +Module-level helpers +-------------------- +* :data:`_KIND_ORDER` -- canonical display order for level kinds +* :func:`_build_page_map` -- ``{level_name: page_stem}`` mapping +""" + +from typing import Dict + +from lobster.common.report import Report +from lobster.common.items import Tracing_Status, Item, Requirement + +from ._helpers import RstUtils, ItemNaming, TracingClassifier, PolicyDiagramBuilder + + +#: Canonical display order: list of ``(kind_value, section_title)`` pairs. +_KIND_ORDER = [ + ("requirements", "Requirements and Specification"), + ("implementation", "Implementation"), + ("activity", "Verification and Validation"), +] + + +def _build_page_map(report: Report) -> Dict[str, str]: + """Build a mapping from level name to filesystem-safe page stem. + + Duplicate stems (produced when two level names collapse to the same slug) + are disambiguated by appending a numeric suffix. + + Args: + report: The loaded LOBSTER report. + + Returns: + A ``dict`` of ``{level_name: page_stem}`` where every value is a + unique, filesystem-safe string without a file extension. + """ + seen: Dict[str, int] = {} + page_map: Dict[str, str] = {} + for level_name in report.config: + stem = ItemNaming.level_page_name(level_name) + if stem in seen: + seen[stem] += 1 + stem = f"{stem}_{seen[stem]}" + else: + seen[stem] = 0 + page_map[level_name] = stem + return page_map + + +class ItemCardBuilder: + """Build a sphinx-design card for a single LOBSTER tracing item. + + The card is structured in three sections: + + * **Header** (coloured stripe) -- status badge and item name, styled via + the ``^^^`` separator so that ``:class-header:`` CSS is applied. + * **Body** -- workflow status, description, tracing links, and any inline + issue messages (no ``.. warning::`` admonition box). + * **Footer** -- source location link. + + Usage:: + + lines = ItemCardBuilder(item, report, source_root).build() + """ + + def __init__(self, item: Item, report: Report, source_root: str = ""): + """Initialise the builder. + + Args: + item: The LOBSTER item to render. + report: The full report, needed to resolve cross-references. + source_root: Optional URL prefix prepended to plain file-reference + paths (see :meth:`ItemNaming.location_link`). + """ + self._item = item + self._report = report + self._source_root = source_root + + def build(self) -> list: + """Return RST lines for the item dropdown. + + All items use ``.. dropdown::``. OK and JUSTIFIED items start closed + (low visual weight); all other statuses start open. Issue messages + are rendered as nested open red dropdowns -- no custom CSS required. + + Returns: + A list of RST lines ending with a blank string. + """ + item = self._item + e = RstUtils.escape + status = item.tracing_status.name if item.tracing_status else "UNKNOWN" + kind_str = ItemNaming.item_kind_str(item) + down_msgs, up_msgs, gen_msgs = TracingClassifier.categorize(item.messages) + is_ok = status in ("OK", "JUSTIFIED") + hdr_class = TracingClassifier.card_header_class(status) + title = f"[{status}] {e(kind_str)} {e(item.name)}" + + out = [] + + # Anchor label + out.append(f".. _{ItemNaming.item_label(item)}:") + out.append("") + + # lobster-trace: rst_req.RST_Report_Item_Details + # lobster-trace: UseCases.Colored_Findings + # All items are dropdowns; non-OK items start open. + out.append(f".. dropdown:: {title}") + if not is_ok: + out.append(" :open:") + out.append(f" :class-title: {hdr_class}") + out.append("") + + def body(text=""): + """Append a line at 3-space indent (card body level).""" + out.append(f" {text}".rstrip()) + + def nested(text=""): + """Append a line at 6-space indent (nested directive body).""" + out.append(f" {text}".rstrip()) + + def sep(): + """Emit an HTML horizontal rule separator.""" + body(".. raw:: html") + out.append("") + nested("
") + out.append("") + + def issue_box(msgs): + """Render issue messages as a light-red card body (no heading).""" + body(".. card::") + nested(":class-card: lobster-issue-card") + out.append("") + for msg in msgs: + nested(f"* {e(msg)}") + out.append("") + + # Track whether any body content has been emitted (drives separator logic). + has_content = False + + # lobster-trace: rst_req.RST_Report_Item_Details + # Workflow status (requirements only) + if isinstance(item, Requirement) and item.status: + body(f"**Status:** {e(item.status)}") + out.append("") + has_content = True + + # Description text (requirements only) + if isinstance(item, Requirement) and item.text: + body(".. pull-quote::") + out.append("") + for text_line in item.text.splitlines(): + nested(e(text_line)) + out.append("") + has_content = True + + # lobster-trace: UseCases.List_Requirements_to_Tests + # lobster-trace: UseCases.List_Tests_to_Requirements + # Downward traces section + has_down = bool(item.ref_down or down_msgs) + if has_down: + if has_content: + sep() + body("**Traces to:**") + out.append("") + for ref_str in self._resolve_refs(item.ref_down): + body(f"* {ref_str}") + if item.ref_down: + out.append("") + if down_msgs: + issue_box(down_msgs) + has_content = True + + # lobster-trace: UseCases.List_Requirements_without_Tests + # lobster-trace: UseCases.List_Tests_without_Requirements + # Upward traces section + has_up = bool(item.ref_up or up_msgs) + if has_up: + if has_content: + sep() + body("**Derived from:**") + out.append("") + for ref_str in self._resolve_refs(item.ref_up): + body(f"* {ref_str}") + if item.ref_up: + out.append("") + if up_msgs: + issue_box(up_msgs) + has_content = True + + # Justification text + if item.tracing_status == Tracing_Status.JUSTIFIED: + justifications = item.just_global + item.just_up + item.just_down + if justifications: + if has_content: + sep() + body(f"**Justifications:** {'; '.join(e(j) for j in justifications)}") + out.append("") + has_content = True + + # General messages (not clearly upward or downward) + if gen_msgs: + if has_content: + sep() + issue_box(gen_msgs) + + # lobster-trace: UseCases.Item_GitHub_Source + # lobster-trace: UseCases.Show_codebeamer_links + # Source location: always inline at end (.. dropdown:: has no +++ footer). + source_link = ItemNaming.location_link(item.location, self._source_root) + if has_content or gen_msgs: + sep() + body(f"**Source:** {source_link}") + out.append("") + + return out + + def _resolve_refs(self, refs) -> list: + """Resolve tracing references to RST cross-reference strings. + + Known items are rendered as ``:ref:`` links; unresolvable references + fall back to a code literal of the reference key. + + Args: + refs: An iterable of reference objects with a ``key()`` method. + + Returns: + A list of RST inline strings (one per reference). + """ + parts = [] + for ref in refs: + key = ref.key() + if key in self._report.items: + ref_item = self._report.items[key] + parts.append( + f":ref:`{RstUtils.escape(ref_item.name)}" + f" <{ItemNaming.item_label(ref_item)}>`" + ) + else: + parts.append(f"``{RstUtils.escape(key)}`` (unresolved)") + return parts + + +class LevelSectionBuilder: + """Build the RST body for a single tracing level. + + Issue items (open red dropdowns) are emitted before OK items (closed green + dropdowns). The dropdown open/closed state communicates status visually, + so no filter TOC or sub-section headings are added. + + Usage:: + + lines = LevelSectionBuilder(items, report, source_root).build() + """ + + def __init__(self, items: list, report: Report, source_root: str = ""): + """Initialise the builder. + + Args: + items: The list of LOBSTER items belonging to this level. + report: The full report, needed for cross-reference resolution. + source_root: Optional URL prefix prepended to file-reference paths. + """ + self._items = items + self._report = report + self._source_root = source_root + + def build(self) -> list: + """Return RST lines for the level body. + + Issue items (non-OK/non-JUSTIFIED) come first, sorted by location, + followed by OK/JUSTIFIED items. No filter TOC or sub-headings are + emitted; the dropdown open/closed state communicates status visually. + + Returns: + A list of RST lines. + """ + issue_items = sorted( + ( + it + for it in self._items + if it.tracing_status + not in (Tracing_Status.OK, Tracing_Status.JUSTIFIED) + ), + key=lambda x: x.location.sorting_key(), + ) + ok_items = sorted( + ( + it + for it in self._items + if it.tracing_status in (Tracing_Status.OK, Tracing_Status.JUSTIFIED) + ), + key=lambda x: x.location.sorting_key(), + ) + + out = [] + for it in issue_items + ok_items: + out += ItemCardBuilder(it, self._report, self._source_root).build() + + if not out: + out += ["No items recorded at this level.", ""] + + return out + + +class CoverageGridBuilder: + """Build the coverage summary section (table + policy diagram side by side). + + Uses a sphinx-design ``.. grid:: 1 1 2 2`` layout: + + * Left column (7/12 width on desktop) -- a ``.. list-table::`` with + per-level coverage statistics and links to each level. + * Right column (5/12 width on desktop) -- the :class:`PolicyDiagramBuilder` + graphviz diagram showing level kinds and tracing relationships. + + Usage:: + + ref_fn = lambda n: f":doc:`{n} `" + lines = CoverageGridBuilder(report).build(ref_fn) + """ + + def __init__(self, report: Report): + """Initialise the builder. + + Args: + report: The loaded LOBSTER report, providing coverage data and config. + """ + self._report = report + + def build(self, ref_fn) -> list: + """Return RST lines for the coverage grid. + + Args: + ref_fn: A callable ``(level_name: str) -> str`` returning an RST + cross-reference for the given level. Use ``:ref:`` links for + single-page output and ``:doc:`` links for multi-page output. + + Returns: + A list of RST lines ending with a blank string. + """ + # lobster-trace: UseCases.Item_Coverage + # lobster-trace: rst_req.RST_Report_Coverage_Table + lines = [] + lines += [".. grid:: 1 1 2 2", " :gutter: 3", ""] + lines += [" .. grid-item::", " :columns: 12 12 7 7", ""] + lines += [ + " .. list-table::", + " :header-rows: 1", + " :widths: 35 15 15 15", + "", + ] + lines += [ + " * - Category", + " - Coverage", + " - OK Items", + " - Total Items", + ] + for level_name in self._report.config: + data = self._report.coverage[level_name] + lines.append(f" * - {ref_fn(level_name)}") + lines.append(f" - {data.coverage:.1f}%") + lines.append(f" - {data.ok}") + lines.append(f" - {data.items}") + lines.append("") + lines += [" .. grid-item::", " :columns: 12 12 5 5", ""] + lines += PolicyDiagramBuilder.build(self._report, indent=6) + return lines + + +class IssuesListBuilder: + """Build the index-page issues summary list. + + Each issue is rendered as a bullet with a granular tag derived from the + message text (e.g. ``[MISSING — version mismatch]``) and a cross-page + ``:ref:`` link to the corresponding item card. + + Usage:: + + lines = IssuesListBuilder(report).build() + """ + + def __init__(self, report: Report): + """Initialise the builder. + + Args: + report: The loaded LOBSTER report. + """ + self._report = report + + def build(self) -> list: + """Return RST lines for the issues list. + + Returns: + A list of RST bullet items, one per issue message. If no issues + exist, a single ``"No traceability issues found."`` line is returned. + """ + # lobster-trace: rst_req.RST_Report_Issues_List + # lobster-trace: UseCases.List_Findings + lines = [] + found_any = False + for item in sorted( + self._report.items.values(), key=lambda x: x.location.sorting_key() + ): + if item.tracing_status in (Tracing_Status.OK, Tracing_Status.JUSTIFIED): + continue + for message in item.messages: + tag = TracingClassifier.issue_tag(message) + item_ref = ( + f":ref:`{RstUtils.escape(item.name)}" + f" <{ItemNaming.item_label(item)}>`" + ) + lines.append(f"* [{item.tracing_status.name} — {tag}] {item_ref}") + found_any = True + if not found_any: + lines.append("No traceability issues found.") + return lines diff --git a/lobster/tools/core/rst_report/input_file.trlc b/lobster/tools/core/rst_report/input_file.trlc new file mode 100644 index 00000000..6cdd4931 --- /dev/null +++ b/lobster/tools/core/rst_report/input_file.trlc @@ -0,0 +1,27 @@ +package rst_req +import req + +req.System_Requirement Missing_Lobster_File { + description = ''' + IF the input .lobster file does not exist or is missing, + THEN the tool shall exit with a non-zero return code (2) + AND shall print an error message to STDERR indicating that the specified file is not a valid file. + ''' +} + +req.System_Requirement Valid_Lobster_File { + description = ''' + IF a valid .lobster file is provided as input, + THEN the tool shall execute successfully with a zero return code (0) + AND shall write a valid RST report to the specified output file (single-page mode). + ''' +} + +req.System_Requirement Valid_Lobster_File_Multi_Page { + description = ''' + IF a valid .lobster file is provided as input together with the --out-dir option, + THEN the tool shall execute successfully with a zero return code (0) + AND shall write a set of RST files (index.rst and one file per tracing level) + to the specified output directory. + ''' +} diff --git a/lobster/tools/core/rst_report/requirements/potential_errors.trlc b/lobster/tools/core/rst_report/requirements/potential_errors.trlc new file mode 100644 index 00000000..6662cfe1 --- /dev/null +++ b/lobster/tools/core/rst_report/requirements/potential_errors.trlc @@ -0,0 +1,170 @@ +package UseCases +import req + +req.PotentialError RST_Report_not_Generated { + summary = "LOBSTER fails to generate the RST report" + description = ''' + The RST report is not generated when it should have been. + ''' + impacts = [ + '''If the RST report is not generated then the user will not be able to + verify the traceability results between requirements and software tests + in their Sphinx documentation.''' + ] + + affects = [ + RST_Output, + Colored_Findings, + English_Language, + ] + impact_type = req.Impact_Type.Financial +} + +req.PotentialError RST_Requirements_not_listed_correctly { + summary = "LOBSTER does not list the requirements correctly in the RST report" + description = ''' + Some requirements are not listed correctly in the RST report. + For example: + - Some requirements which are not covered are not listed. + - Some requirements which are covered are not listed. + ''' + impacts = [ + '''If requirements not covered by tests are missing from the RST report + then the user might think that all requirements are covered by tests + when in fact they are not.''', + + '''If requirements covered by tests are missing from the RST report + then the user might think that no requirements are covered + when in fact they are.''' + ] + + affects = [List_Requirements_to_Tests, List_Requirements_without_Tests] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Software_tests_not_listed_correctly { + summary = "LOBSTER does not list the software tests correctly in the RST report" + description = ''' + Some software tests are not listed correctly in the RST report. + For example: + - Some software tests not covering any requirement are listed. + - Some software tests covering a requirement are not listed. + ''' + impacts = [ + '''If software tests not covering any requirement are missing from the RST report + then the user might think that all requirements are covered + when in fact they are not.''', + + '''If software tests covering requirements are missing from the RST report + then the user might think no tests cover requirements + when in fact they do.''' + ] + + affects = [List_Tests_to_Requirements, List_Tests_without_Requirements] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Incorrect_Coverage { + summary = "LOBSTER RST report shows a wrong coverage value." + description = ''' + The tool writes a coverage value into the output file + that is not equal to the coverage value from the input file. + ''' + impacts = [ + '''If the displayed coverage value is higher than the actual value, + the quality manager may release software which should not be released.''' + ] + + affects = [Item_Coverage] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Too_few_findings { + summary = "LOBSTER RST report lists too few findings" + description = ''' + Not all tracing policy violations are listed in the RST report. + For example, a missing requirement reference in a software test is not detected. + ''' + impacts = [ + '''If all tracing policy violations are not listed in the RST report + then the user might think that the traceability policy is followed + in all test cases when in fact it is not.''' + ] + + affects = [RST_Output, List_Findings] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Input_file_location_missing { + summary = "LOBSTER RST report fails to show the location of the input files" + description = ''' + Source location of the input file is missing in the RST report. + For example, the URL for a git repo of a software test is not shown. + ''' + impacts = [ + '''If the URL for the git repo is missing in the RST report + then the user will not be able to navigate to the corresponding + location in the repository.''', + + '''If the link to a requirement item is missing + then the user will not be able to access the codebeamer item.''' + ] + + affects = [Item_GitHub_Source, Show_codebeamer_links] + impact_type = req.Impact_Type.Financial +} + +req.PotentialError RST_Wrong_Input_location { + summary = "LOBSTER RST report shows the wrong location of the input source" + description = ''' + The source location rendered in the RST report is incorrect. + For example, the GitHub URL points to the wrong file or line number, + or the Codebeamer link leads to the wrong item. + ''' + impacts = [ + '''If the GitHub URL is incorrect in the RST report + then the user will be navigated to the wrong location in the + repository.''', + + '''If the Codebeamer link is incorrect + then the user will be navigated to the wrong requirement item.''' + ] + + affects = [Item_GitHub_Source, Show_codebeamer_links] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Incorrect_Item_Data { + summary = "LOBSTER RST report shows wrong data for an item" + description = ''' + The tool writes wrong information for an item into the RST output. + For example, the item name, description, or tracing status shown in + the report does not match the data from the input file. + ''' + impacts = [ + '''If the rendered item data shows a higher quality than the actual + item data, the quality manager may decide to release software + that should not be released.''' + ] + + affects = [Item_Coverage] + impact_type = req.Impact_Type.Safety +} + +req.PotentialError RST_Incorrect_RST_Syntax { + summary = "LOBSTER generates RST output with incorrect syntax" + description = ''' + The generated RST contains syntax errors or constructs that cause + Sphinx to emit warnings or fail during the build. + For example, broken cross-references, malformed directives, or + unescaped special characters. + ''' + impacts = [ + '''If the RST output contains syntax errors then the Sphinx build + may fail or produce a report with missing or garbled content, + preventing the user from reviewing the traceability results.''' + ] + + affects = [RST_Output] + impact_type = req.Impact_Type.Financial +} diff --git a/lobster/tools/core/rst_report/requirements/requirements.trlc b/lobster/tools/core/rst_report/requirements/requirements.trlc new file mode 100644 index 00000000..229ba536 --- /dev/null +++ b/lobster/tools/core/rst_report/requirements/requirements.trlc @@ -0,0 +1,49 @@ +package rst_req +import req + +req.System_Requirement RST_Report_Tracing_Policy_Diagram { + description = ''' + The generated RST report SHALL include a Graphviz diagram + that visualises the tracing policy used to produce the report. + ''' +} + +req.System_Requirement RST_Report_Coverage_Table { + description = ''' + The generated RST report SHALL include a coverage summary table + that shows for each tracing level the number of items + and the percentage of items that fulfill the tracing policy. + ''' +} + +req.System_Requirement RST_Report_Issues_List { + description = ''' + The generated RST report SHALL include an issues section + that lists all items where the tracing policy is violated. + ''' +} + +req.System_Requirement RST_Report_Item_Details { + description = ''' + The generated RST report SHALL include a detail section for every item + in the report, showing the item tag, description, status, and its + incoming and outgoing references. + ''' +} + +req.System_Requirement RST_Report_Single_Page { + description = ''' + IF the user invokes the tool with the --out option, + THEN the tool SHALL write the complete traceability report + as a single RST file to the path specified by --out. + ''' +} + +req.System_Requirement RST_Report_Multi_Page { + description = ''' + IF the user invokes the tool with the --out-dir option, + THEN the tool SHALL write the traceability report as a set of RST files + to the directory specified by --out-dir. + The set SHALL consist of an index.rst file and one RST file per tracing level. + ''' +} diff --git a/lobster/tools/core/rst_report/requirements/rst_report_content.trlc b/lobster/tools/core/rst_report/requirements/rst_report_content.trlc new file mode 100644 index 00000000..75333f64 --- /dev/null +++ b/lobster/tools/core/rst_report/requirements/rst_report_content.trlc @@ -0,0 +1,29 @@ +package rst_req +import req + +req.System_Requirement RST_Clickable_Codebeamer_Item { + description = ''' + IF the input LOBSTER report file contains codebeamer items, + THEN the generated RST report SHALL represent these as hyperlinks + to the item on the codebeamer server, + where the codebeamer server URL is taken from the cb_root configuration. + ''' +} + +req.System_Requirement RST_Codebeamer_Item_Name { + description = ''' + IF the input LOBSTER report file contains codebeamer items with a name, + THEN the generated RST report SHALL display the item using its + codebeamer item name in the hyperlink text. + ''' +} + +req.System_Requirement RST_Source_Root_Prefix { + description = ''' + IF the --source-root option is provided, + THEN the generated RST report SHALL prepend the given prefix + to all file reference URLs so that links resolve correctly + when the RST output is included in a Sphinx project at a different + directory level. + ''' +} diff --git a/lobster/tools/core/rst_report/requirements/test_specifications.trlc b/lobster/tools/core/rst_report/requirements/test_specifications.trlc new file mode 100644 index 00000000..904fedfa --- /dev/null +++ b/lobster/tools/core/rst_report/requirements/test_specifications.trlc @@ -0,0 +1,90 @@ +package UseCases +import req + +req.TestSpecification RST_File_generation { + description = ''' + The test shall verify that the RST report file is generated when + a valid .lobster input file is provided. + ''' + verifies = [RST_Report_not_Generated] +} + +req.TestSpecification RST_Covered_Requirement_list { + description = ''' + The test shall verify that requirements covered by software tests + are correctly listed in the RST report. + ''' + verifies = [RST_Requirements_not_listed_correctly] +} + +req.TestSpecification RST_Not_covered_Requirement_list { + description = ''' + The test shall verify that requirements not covered by software tests + are correctly listed in the RST report. + ''' + verifies = [RST_Requirements_not_listed_correctly] +} + +req.TestSpecification RST_List_of_tests_covering_requirements { + description = ''' + The test shall verify that software tests covering requirements + are correctly listed in the RST report. + ''' + verifies = [RST_Software_tests_not_listed_correctly] +} + +req.TestSpecification RST_List_of_tests_not_covering_requirements { + description = ''' + The test shall verify that software tests not covering any requirement + are correctly listed in the RST report. + ''' + verifies = [RST_Software_tests_not_listed_correctly] +} + +req.TestSpecification RST_Coverage_in_output { + description = ''' + The test shall verify correct coverage value in the RST report. + ''' + verifies = [RST_Incorrect_Coverage] +} + +req.TestSpecification RST_Missing_tracing_policy_violation_in_output { + description = ''' + The test shall verify that items where the tracing policy is violated + are listed correctly in the RST report. + ''' + verifies = [RST_Too_few_findings] +} + +req.TestSpecification RST_Source_location_in_output { + description = ''' + The test shall verify that correct source locations of the inputs (URLs) + are mentioned in the RST report. + ''' + verifies = [RST_Input_file_location_missing, RST_Wrong_Input_location] +} + +req.TestSpecification RST_Correct_Item_Data { + description = ''' + The test shall verify that correct item data (name, description, tracing + status) is displayed in the RST report and is not mixed between items. + ''' + verifies = [RST_Incorrect_Item_Data] +} + +req.TestSpecification RST_Valid_Sphinx_Build { + description = ''' + The test shall verify that the generated RST output can be + successfully built by Sphinx without warnings or errors. + ''' + verifies = [RST_Incorrect_RST_Syntax] +} + +req.TestSpecification RST_Codebeamer_Links_In_Output { + description = ''' + The test shall verify that codebeamer items in the input LOBSTER + report are rendered as clickable hyperlinks in the RST output, + including correct URL construction and item name display. + ''' + verifies = [RST_Input_file_location_missing, RST_Wrong_Input_location] +} diff --git a/lobster/tools/core/rst_report/rst_report.py b/lobster/tools/core/rst_report/rst_report.py new file mode 100644 index 00000000..899abe84 --- /dev/null +++ b/lobster/tools/core/rst_report/rst_report.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +# +# lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +""" +LOBSTER RST report tool -- top-level orchestration and CLI. + +This module is intentionally thin: it wires together the helpers from +:mod:`_helpers` and the builder classes from :mod:`_renderers` to produce +complete RST documents, and exposes the CLI entry-point :func:`main`. + +Public API +---------- +* :func:`write_rst` -- single-page RST string +* :func:`write_rst_to_file` -- single-page RST to file +* :func:`write_rst_pages` -- multi-page RST dict (filename to content) +* :func:`write_rst_pages_to_dir` -- write multi-page dict to a directory +* :func:`lobster_rst_report` -- convenience wrapper (single-page) +* :func:`lobster_rst_report_pages` -- convenience wrapper (multi-page) +* :func:`main` -- argparse CLI entry-point +""" + +import os +import argparse +from datetime import datetime, timezone +from typing import Dict, Optional, Sequence + +from lobster.common.version import LOBSTER_VERSION +from lobster.common.report import Report +from lobster.common.io import ensure_output_directory +from lobster.common.meta_data_tool_base import MetaDataToolBase +from lobster.common.exceptions import LOBSTER_Exception +from lobster.common.errors import LOBSTER_Error +from lobster.common.graphviz_utils import is_dot_available + +from ._helpers import RstUtils, ItemNaming +from ._renderers import ( + _KIND_ORDER, + _build_page_map, + LevelSectionBuilder, + CoverageGridBuilder, + IssuesListBuilder, +) + + +# --------------------------------------------------------------------------- +# Single-page output +# --------------------------------------------------------------------------- +# +# Heading hierarchy (first appearance determines RST level): +# overline/# -> document title +# = -> kind groups (Requirements and Specification, ...) +# - -> level names (System Requirements, ...) +# ~ -> status groups inside each level (Issues N items, OK Items M items) + + +def write_rst(report: Report, source_root: str = "") -> str: + """Render a LOBSTER report as a single reStructuredText string. + + The document contains a coverage summary grid (table + tracing-policy + diagram), an issues list, and then the full item detail for every level + grouped by kind. Coverage Summary and Issues use ``.. rubric::`` so they + do not appear as TOC entries in the Sphinx sidebar. + + Args: + report: A loaded :class:`~lobster.common.report.Report` instance. + source_root: Optional URL prefix prepended to plain file-reference + paths. + + Returns: + The complete RST document as a string (ending with a newline). + """ + lines = [] + + title = "L.O.B.S.T.E.R. Traceability Report" + lines += RstUtils.heading(title, "#", overline=True) + lines.append("") + now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines.append(f"| Generated: {now}") + lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") + lines.append("") + + # Coverage table + tracing-policy diagram (rubric = not a TOC entry) + lines.append(".. rubric:: Coverage Summary") + lines.append("") + + def ref_fn(n): + return f":ref:`{RstUtils.escape(n)} <{ItemNaming.level_label(n)}>`" + + lines += CoverageGridBuilder(report).build(ref_fn) + + # Issues summary (rubric = not a TOC entry) + lines.append(".. rubric:: Issues") + lines.append("") + lines += IssuesListBuilder(report).build() + lines.append("") + + # Detailed content: kind groups and level sections + items_by_level = { + lv: [it for it in report.items.values() if it.level == lv] + for lv in report.config + } + + for kind, kind_title in _KIND_ORDER: + levels_of_kind = [lv for lv in report.config.values() if lv.kind == kind] + if not levels_of_kind: + continue + + lines += RstUtils.heading(kind_title, "=") + lines.append("") + + for level in levels_of_kind: + lines.append(f".. _{ItemNaming.level_label(level.name)}:") + lines.append("") + lines += RstUtils.heading(level.name, "-") + lines.append("") + + items = items_by_level[level.name] + if not items: + lines.append("No items recorded at this level.") + lines.append("") + continue + + data = report.coverage[level.name] + lines.append( + f"**Coverage:** {data.coverage:.1f}%" + f" ({data.ok} of {data.items} items OK)" + ) + lines.append("") + lines += LevelSectionBuilder(items, report, source_root).build() + + return "\n".join(lines) + "\n" + + +def write_rst_to_file(rst_content: str, output_path: str) -> None: + """Write RST content to *output_path*, creating parent directories as needed. + + Args: + rst_content: The RST string to write. + output_path: The destination file path. + """ + ensure_output_directory(output_path) + with open(output_path, "w", encoding="UTF-8") as fd: + fd.write(rst_content) + + +# --------------------------------------------------------------------------- +# Multi-page output +# --------------------------------------------------------------------------- +# +# Heading hierarchy: +# index.rst -- overline/# title only; Coverage/Issues are rubrics (not in +# TOC); kind groups are toctree :caption: entries (not headings) +# level pages -- = for page title, - for Issues/OK Items sub-sections + + +def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]: + """Render a LOBSTER report as a set of linked RST pages. + + Produces one RST file per tracing level plus an ``index.rst`` that links + them together. The index page contains the coverage grid, issues list, + and per-kind ``.. toctree::`` directives whose ``:caption:`` entries + appear in the Sphinx sidebar without adding clickable heading nodes. + + Args: + report: A loaded :class:`~lobster.common.report.Report` instance. + source_root: Optional URL prefix prepended to plain file-reference + paths. + + Returns: + A ``dict`` mapping filename (e.g. ``"index.rst"``, + ``"system_requirements.rst"``) to RST content strings. + """ + page_map = _build_page_map(report) + coverage = report.coverage + items_by_level = { + lv: [it for it in report.items.values() if it.level == lv] + for lv in report.config + } + + pages = {} + + # -- Level pages -- + for level_name in report.config: + stem = page_map[level_name] + data = coverage[level_name] + items = items_by_level[level_name] + + lines = [] + lines += RstUtils.heading(level_name, "=") + lines.append("") + + if not items: + lines.append("No items recorded at this level.") + lines.append("") + else: + lines.append( + f"**Coverage:** {data.coverage:.1f}%" + f" ({data.ok} of {data.items} items OK)" + ) + lines.append("") + lines += LevelSectionBuilder(items, report, source_root).build() + + pages[f"{stem}.rst"] = "\n".join(lines) + "\n" + + # -- Index page -- + lines = [] + title = "L.O.B.S.T.E.R. Traceability Report" + lines += RstUtils.heading(title, "#", overline=True) + lines.append("") + now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines.append(f"| Generated: {now}") + lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") + lines.append("") + + # Coverage table + policy diagram (rubric = not a TOC entry) + lines.append(".. rubric:: Coverage Summary") + lines.append("") + + def ref_fn(n): + return f":doc:`{RstUtils.escape(n)} <{page_map[n]}>`" + + lines += CoverageGridBuilder(report).build(ref_fn) + + # Issues list (rubric = not a TOC entry) + lines.append(".. rubric:: Issues") + lines.append("") + lines += IssuesListBuilder(report).build() + lines.append("") + + # Per-kind toctrees -- :caption: shows in sidebar but doesn't create a + # heading node, so clicking a level goes straight to that level's page + for kind, kind_title in _KIND_ORDER: + levels_of_kind = [lv for lv in report.config.values() if lv.kind == kind] + if not levels_of_kind: + continue + lines.append(".. toctree::") + lines.append(f" :caption: {kind_title}") + lines.append(" :maxdepth: 1") + lines.append("") + for lv in levels_of_kind: + lines.append(f" {page_map[lv.name]}") + lines.append("") + + pages["index.rst"] = "\n".join(lines) + "\n" + + return pages + + +def write_rst_pages_to_dir(pages: Dict[str, str], out_dir: str) -> None: + """Write multi-page RST output to *out_dir*, creating it if necessary. + + Args: + pages: A ``dict`` mapping filename to RST content, as returned by + :func:`write_rst_pages`. + out_dir: The directory to write files into. Created if it does not + already exist. + """ + os.makedirs(out_dir, exist_ok=True) + for filename, content in pages.items(): + filepath = os.path.join(out_dir, filename) + with open(filepath, "w", encoding="UTF-8") as fd: + fd.write(content) + + +# --------------------------------------------------------------------------- +# CLI tool +# --------------------------------------------------------------------------- + + +class RstReportTool(MetaDataToolBase): + """Argparse-based CLI tool for generating RST reports from LOBSTER data. + + Registered as the ``rst-report`` tool in the LOBSTER tool registry. + Supports both single-page (``--out``) and multi-page (``--out-dir``) output. + """ + + def __init__(self): + """Register the tool with its name, description, and argument parser.""" + super().__init__( + name="rst-report", + description="Visualise LOBSTER report as reStructuredText for Sphinx", + official=True, + ) + + ap = self._argument_parser + ap.add_argument( + "lobster_report", + nargs="?", + default="report.lobster", + help="path to the LOBSTER report file (default: report.lobster)", + ) + + output_group = ap.add_mutually_exclusive_group() + output_group.add_argument( + "--out", + default=None, + metavar="FILE", + help="write single-page RST to FILE (default: lobster_report.rst)", + ) + output_group.add_argument( + "--out-dir", + default=None, + metavar="DIR", + help="write multi-page RST to DIR (index.rst + one page per level)", + ) + + ap.add_argument( + "--source-root", + default="", + help="prefix prepended to file reference URLs, e.g. a relative " + "path from the RST output location back to the workspace root", + ) + + def _run_impl(self, options: argparse.Namespace) -> int: + """Execute the tool with the parsed command-line options. + + Args: + options: Parsed argument namespace from argparse. + + Returns: + Integer exit code (0 on success). + """ + # lobster-trace: rst_req.Missing_Lobster_File + if not os.path.isfile(options.lobster_report): + self._argument_parser.error(f"{options.lobster_report} is not a file") + + try: + report = Report() + report.load_report(options.lobster_report) + except LOBSTER_Error as err: + print(err) + print(f"{self.name}: aborting due to earlier errors.") + return 1 + except LOBSTER_Exception as err: + err.dump() + return 1 + + if not is_dot_available(): + print( + "warning: dot utility not found, report will not include " + "the tracing policy visualisation" + ) + print("> please install Graphviz (https://graphviz.org)") + + # lobster-trace: UseCases.RST_Output + # lobster-trace: rst_req.RST_Report_Multi_Page + # lobster-trace: rst_req.Valid_Lobster_File_Multi_Page + if options.out_dir is not None: + pages = write_rst_pages(report=report, source_root=options.source_root) + write_rst_pages_to_dir(pages, options.out_dir) + print( + f"LOBSTER RST report written to {options.out_dir}/ ({len(pages)} files)" + ) + # lobster-trace: UseCases.RST_Output + # lobster-trace: rst_req.RST_Report_Single_Page + # lobster-trace: rst_req.Valid_Lobster_File + else: + out_path = options.out if options.out is not None else "lobster_report.rst" + rst_content = write_rst(report=report, source_root=options.source_root) + write_rst_to_file(rst_content, out_path) + print(f"LOBSTER RST report written to {out_path}") + + return 0 + + +# --------------------------------------------------------------------------- +# Public convenience API +# --------------------------------------------------------------------------- + + +def lobster_rst_report( + lobster_report_path: str, + output_rst_path: str, + source_root: str = "", +) -> None: + """Generate a single-page RST report from a LOBSTER report file. + + Args: + lobster_report_path: Path to the input ``.lobster`` report file. + output_rst_path: Path to the output RST file to create. + source_root: Optional URL prefix prepended to file-reference paths. + """ + report = Report() + report.load_report(lobster_report_path) + write_rst_to_file( + write_rst(report=report, source_root=source_root), output_rst_path + ) + + +def lobster_rst_report_pages( + lobster_report_path: str, + output_dir: str, + source_root: str = "", +) -> None: + """Generate a multi-page RST report from a LOBSTER report file. + + Args: + lobster_report_path: Path to the input ``.lobster`` report file. + output_dir: Directory to write RST pages into. + source_root: Optional URL prefix prepended to file-reference paths. + """ + report = Report() + report.load_report(lobster_report_path) + write_rst_pages_to_dir( + write_rst_pages(report=report, source_root=source_root), + output_dir, + ) + + +def main(args: Optional[Sequence[str]] = None) -> int: + """Entry point for the ``lobster-rst-report`` command-line tool. + + Args: + args: Optional list of CLI argument strings. When ``None`` (default), + ``sys.argv[1:]`` is used. + + Returns: + Integer exit code (0 on success). + """ + return RstReportTool().run(args) diff --git a/lobster/use_cases.trlc b/lobster/use_cases.trlc index 70fd989e..4e711fcd 100644 --- a/lobster/use_cases.trlc +++ b/lobster/use_cases.trlc @@ -18,7 +18,8 @@ req.UseCase List_Requirements_to_Tests { // shall generate reports: req.Tools.lobster_report, - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -39,7 +40,8 @@ req.UseCase List_Requirements_without_Tests { // shall generate reports: req.Tools.lobster_report, - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -60,7 +62,8 @@ req.UseCase List_Tests_to_Requirements { // shall generate reports: req.Tools.lobster_report, - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -81,7 +84,8 @@ req.UseCase List_Tests_without_Requirements { // shall generate reports: req.Tools.lobster_report, - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -110,7 +114,8 @@ req.UseCase Item_Coverage { // shall generate reports: req.Tools.lobster_report, - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -121,6 +126,8 @@ req.UseCase Show_Tracing_Policy { ''' affected_tools = [ req.Tools.lobster_report, + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -137,7 +144,8 @@ req.UseCase Show_codebeamer_links { req.Tools.lobster_report, // shall render the source URL - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -168,7 +176,8 @@ req.UseCase Item_GitHub_Source { req.Tools.lobster_online_report, // shall render the references - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -182,13 +191,28 @@ req.UseCase HTML_Output { ] } +req.UseCase RST_Output { + description = ''' + As a requirements manager I want the traceability report to be generated as + reStructuredText (RST) for inclusion in a Sphinx documentation project, + with the option to produce either a single combined document or a set of + individual pages, one per tracing level. + ''' + affected_tools = [ + req.Tools.lobster_rst_report + ] +} + req.UseCase List_Findings { description = ''' As a requirements manager I want the traceability report to show a list with all findings where the tracing policy is violated. ''' - affected_tools = [req.Tools.lobster_html_report] + affected_tools = [ + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report + ] } section "Nice to have" { @@ -203,7 +227,8 @@ section "Nice to have" { to highlight the missing traces and detected traces in different color. ''' affected_tools = [ - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } @@ -213,7 +238,8 @@ section "Nice to have" { to be generated in English language. ''' affected_tools = [ - req.Tools.lobster_html_report + req.Tools.lobster_html_report, + req.Tools.lobster_rst_report ] } diff --git a/packages/lobster-core/setup.py b/packages/lobster-core/setup.py index d89fe9e0..173bcc1d 100644 --- a/packages/lobster-core/setup.py +++ b/packages/lobster-core/setup.py @@ -51,7 +51,8 @@ "lobster.tools.core.html_report", "lobster.tools.core.online_report", "lobster.tools.core.online_report_nogit", - "lobster.tools.core.report"], + "lobster.tools.core.report", + "lobster.tools.core.rst_report"], install_requires=[ "Markdown~=3.7", "PyYAML>=6.0", @@ -73,7 +74,8 @@ "lobster-html-report=lobster.tools.core.html_report.html_report:main", "lobster-online-report=lobster.tools.core.online_report.online_report:main", "lobster-online-report-nogit=lobster.tools.core.online_report_nogit.online_report_nogit:main", - "lobster-ci-report=lobster.tools.core.ci_report.ci_report:main" + "lobster-ci-report=lobster.tools.core.ci_report.ci_report:main", + "lobster-rst-report=lobster.tools.core.rst_report.rst_report:main" ] }, ) diff --git a/packages/lobster-monolithic/setup.py b/packages/lobster-monolithic/setup.py index 9e4339ae..635153cc 100644 --- a/packages/lobster-monolithic/setup.py +++ b/packages/lobster-monolithic/setup.py @@ -82,7 +82,8 @@ "lobster-gtest = lobster.tools.gtest.gtest:main", "lobster-json = lobster.tools.json.json:main", "lobster-trlc = lobster.tools.trlc.trlc_tool:main", - "lobster-pkg = lobster.tools.pkg.pkg:main" + "lobster-pkg = lobster.tools.pkg.pkg:main", + "lobster-rst-report = lobster.tools.core.rst_report.rst_report:main" ] }, ) diff --git a/requirements_dev.txt b/requirements_dev.txt index 56d54ea6..184f8ec8 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,6 @@ -r requirements.txt sphinx>=7.0.0 +sphinx-design>=0.5.0 graphviz==0.20.3 pycodestyle==2.12.0 pylint==3.2.4 diff --git a/requirements_lock.txt b/requirements_lock.txt index b9ef0bc5..d64be4f6 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -702,6 +702,10 @@ sphinx==7.4.7 \ --hash=sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe \ --hash=sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239 # via -r requirements_dev.txt +sphinx-design==0.6.1 \ + --hash=sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632 \ + --hash=sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c + # via -r requirements_dev.txt sphinxcontrib-applehelp==2.0.0 \ --hash=sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1 \ --hash=sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 diff --git a/tests_integration/projects/sphinx_rst_report/.gitignore b/tests_integration/projects/sphinx_rst_report/.gitignore new file mode 100644 index 00000000..dd74b5d3 --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/.gitignore @@ -0,0 +1,2 @@ +_build/ +traceability/ diff --git a/tests_integration/projects/sphinx_rst_report/BUILD.bazel b/tests_integration/projects/sphinx_rst_report/BUILD.bazel new file mode 100644 index 00000000..9c89b52c --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_python//python:defs.bzl", "py_test") +load("//:requirements.bzl", "requirement") + +py_test( + name = "test_sphinx_build", + srcs = ["test_sphinx_build.py"], + data = [ + "conf.py", + "index.rst", + "_static/custom.css", + "//tests_system/lobster_rst_report/data:rst_report_data_system", + ], + deps = [ + "//lobster/tools/core/rst_report", + requirement("sphinx"), + requirement("sphinx-design"), + ], +) diff --git a/tests_integration/projects/sphinx_rst_report/Makefile b/tests_integration/projects/sphinx_rst_report/Makefile new file mode 100644 index 00000000..4102db6f --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/Makefile @@ -0,0 +1,21 @@ +LOBSTER_ROOT := ../../.. +export PYTHONPATH := $(LOBSTER_ROOT) +export PATH := $(LOBSTER_ROOT):$(PATH) + +LOBSTER_REPORT := ../basic/report.reference_output.lobster +TRACING_DIR := traceability +SPHINX_BUILD := sphinx-build + +.PHONY: html clean + +# Generate the multi-page RST report, then build Sphinx HTML. +html: $(TRACING_DIR)/index.rst + $(SPHINX_BUILD) -b html . _build/html + @echo "" + @echo "HTML report written to _build/html/index.html" + +$(TRACING_DIR)/index.rst: $(LOBSTER_REPORT) + python $(LOBSTER_ROOT)/lobster-rst-report.py $< --out-dir=$(TRACING_DIR) + +clean: + rm -rf _build/ $(TRACING_DIR)/ traceability.rst diff --git a/tests_integration/projects/sphinx_rst_report/_static/custom.css b/tests_integration/projects/sphinx_rst_report/_static/custom.css new file mode 100644 index 00000000..46ec5e24 --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/_static/custom.css @@ -0,0 +1,15 @@ +/* LOBSTER item dropdown: more visible gray border */ +.sd-dropdown { + border: 2px solid #6c757d; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} + +/* LOBSTER issue-card: light pink background with a prominent red border */ +.lobster-issue-card .sd-card-body { + background-color: #f8d7da; +} + +.lobster-issue-card { + border: 2px solid #dc3545; + box-shadow: 0 0 0 1px #dc3545 inset, 0 2px 4px rgba(220, 53, 69, 0.3); +} diff --git a/tests_integration/projects/sphinx_rst_report/conf.py b/tests_integration/projects/sphinx_rst_report/conf.py new file mode 100644 index 00000000..9f82e68e --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/conf.py @@ -0,0 +1,24 @@ +# Sphinx configuration for the LOBSTER RST report sample project. +import os +from datetime import datetime + +project = "LOBSTER Traceability Report" +author = "BMW Software Engineering" +copyright = f"{datetime.now().year}, {author}" + +# No special extensions needed; :ref: is a built-in Sphinx role. +extensions = ["sphinx_design", "sphinx.ext.graphviz"] + +source_suffix = {".rst": "restructuredtext"} +master_doc = "index" + +html_theme = "alabaster" +html_theme_options = { + "description": "Traceability evidence report generated by LOBSTER", + "sidebar_width": "220px", + "page_width": "1100px", + "fixed_sidebar": True, +} + +html_static_path = ["_static"] +html_css_files = ["custom.css"] diff --git a/tests_integration/projects/sphinx_rst_report/index.rst b/tests_integration/projects/sphinx_rst_report/index.rst new file mode 100644 index 00000000..d4ed44a5 --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/index.rst @@ -0,0 +1,19 @@ +LOBSTER Sample Traceability Report +=================================== + +This sample demonstrates generating a Sphinx-based traceability report +using ``lobster-rst-report``. + +The traceability data originates from the ``basic`` integration test project +and contains the same requirements, implementation items, and test activities +that are shown in the HTML report produced by ``lobster-html-report``. + +Build this report by running ``make html`` in this directory. +The generated ``traceability/`` directory (index + one page per traceability +level) is then included via the toctree below. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + traceability/index diff --git a/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py b/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py new file mode 100644 index 00000000..4d9597e3 --- /dev/null +++ b/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# +# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . +"""Integration test: generate RST report and build with Sphinx. + +Verifies that the generated RST output is valid Sphinx input by running +a full Sphinx build with warnings-as-errors (``-W``). +""" + +# lobster-trace: UseCases.RST_Valid_Sphinx_Build + +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +from sphinx.application import Sphinx + +from lobster.tools.core.rst_report.rst_report import main as rst_report_main + + +class SphinxRstReportBuildTest(unittest.TestCase): + """Build a Sphinx project from lobster-rst-report output.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp(prefix="lobster-sphinx-rst-test-") + + # In Bazel, data files are in the runfiles tree. When running + # outside Bazel, fall back to paths relative to this source file. + runfiles_dir = os.environ.get("TEST_SRCDIR") + if runfiles_dir: + ws = os.environ.get("TEST_WORKSPACE", "lobster") + base = Path(runfiles_dir) / ws + self._project_dir = ( + base / "tests_integration" / "projects" / "sphinx_rst_report" + ) + self._lobster_file = ( + base + / "tests_system" + / "lobster_rst_report" + / "data" + / "basic_report.lobster" + ) + else: + self._project_dir = Path(__file__).parent + self._lobster_file = ( + Path(__file__).parents[2] + / "tests_system" + / "lobster_rst_report" + / "data" + / "basic_report.lobster" + ) + + def tearDown(self): + shutil.rmtree(self._tmp, ignore_errors=True) + + def _setup_sphinx_project(self) -> Path: + """Copy project files to temp dir and generate RST traceability pages.""" + work = Path(self._tmp) + + # Copy conf.py and index.rst + shutil.copy2(self._project_dir / "conf.py", work / "conf.py") + shutil.copy2(self._project_dir / "index.rst", work / "index.rst") + + # Copy _static/ + static_src = self._project_dir / "_static" + static_dst = work / "_static" + if static_src.exists(): + shutil.copytree(static_src, static_dst) + + # Copy lobster file + shutil.copy2(self._lobster_file, work / "basic_report.lobster") + + # Generate RST traceability pages + tracing_dir = work / "traceability" + rc = rst_report_main([ + str(work / "basic_report.lobster"), + "--out-dir", str(tracing_dir), + ]) + self.assertEqual(rc, 0, "lobster-rst-report failed") + self.assertTrue( + (tracing_dir / "index.rst").exists(), + "traceability/index.rst not generated", + ) + return work + + def test_sphinx_build_succeeds_without_warnings(self): + """A full Sphinx build must complete without warnings or errors.""" + src_dir = self._setup_sphinx_project() + out_dir = Path(self._tmp) / "_build" / "html" + doctree_dir = Path(self._tmp) / "_build" / "doctrees" + + app = Sphinx( + srcdir=str(src_dir), + confdir=str(src_dir), + outdir=str(out_dir), + doctreedir=str(doctree_dir), + buildername="html", + freshenv=True, + warningiserror=True, + ) + app.build() + + # Verify output exists + self.assertTrue( + (out_dir / "index.html").exists(), + "index.html was not generated", + ) + # Verify traceability pages were built + tracing_html = list(out_dir.glob("traceability/*.html")) + self.assertGreater( + len(tracing_html), 0, + "No traceability HTML pages were generated", + ) + + def test_generated_rst_pages_are_well_formed(self): + """Each generated RST page must be parseable by Sphinx without errors.""" + src_dir = self._setup_sphinx_project() + tracing_dir = src_dir / "traceability" + + # Verify all expected RST files exist + rst_files = list(tracing_dir.glob("*.rst")) + self.assertGreater(len(rst_files), 1, "Expected index + level pages") + + # Verify index.rst has toctree + index_content = (tracing_dir / "index.rst").read_text(encoding="UTF-8") + self.assertIn(".. toctree::", index_content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_system/lobster_rst_report/BUILD.bazel b/tests_system/lobster_rst_report/BUILD.bazel new file mode 100644 index 00000000..0b3fa572 --- /dev/null +++ b/tests_system/lobster_rst_report/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +# BUILD.bazel +# gazelle:exclude __init__.py + +py_library( + name = "lobster_rst_report", + srcs = [ + "lobster_rst_report_system_test_case_base.py", + "lobster_rst_report_test_runner.py", + ], + data = [ + "//tests_system/lobster_rst_report/data:rst_report_data_system", + ], + visibility = [ + "//visibility:public", + ], + deps = [ + "//lobster/tools/core/rst_report", + "//tests_system", + ], +) + +py_test( + name = "test_rst_report_input_file", + srcs = ["test_rst_report_input_file.py"], + deps = [ + ":lobster_rst_report", + "//lobster/tools/core/rst_report", + "//tests_system", + ], +) diff --git a/tests_system/lobster_rst_report/__init__.py b/tests_system/lobster_rst_report/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests_system/lobster_rst_report/data/BUILD.bazel b/tests_system/lobster_rst_report/data/BUILD.bazel new file mode 100644 index 00000000..bdb7bf78 --- /dev/null +++ b/tests_system/lobster_rst_report/data/BUILD.bazel @@ -0,0 +1,7 @@ +filegroup( + name = "rst_report_data_system", + srcs = glob([ + "*.lobster", + ]), + visibility = ["//visibility:public"], +) diff --git a/tests_system/lobster_rst_report/data/basic_report.lobster b/tests_system/lobster_rst_report/data/basic_report.lobster new file mode 100644 index 00000000..5acf199a --- /dev/null +++ b/tests_system/lobster_rst_report/data/basic_report.lobster @@ -0,0 +1,535 @@ +{ + "schema": "lobster-report", + "version": 2, + "generator": "lobster_report", + "levels": [ + { + "name": "System Requirements", + "kind": "requirements", + "items": [ + { + "tag": "req 12345@42", + "location": { + "kind": "codebeamer", + "cb_root": "https://potato.kitten", + "tracker": 12345, + "item": 666, + "version": 42, + "name": "LOBSTER demo" + }, + "name": "LOBSTER demo", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [], + "ref_down": [ + "req example.req_implication@10", + "req example.req_xor", + "req example.req_nand@30" + ], + "tracing_status": "OK", + "framework": "codebeamer", + "kind": "functional requirement", + "text": "Provide a nice demonstration of LOBSTER through four examples", + "status": "Potato" + } + ], + "coverage": 100.0 + }, + { + "name": "Software Requirements", + "kind": "requirements", + "items": [ + { + "tag": "req example.req_implication@10", + "location": { + "kind": "github", + "gh_root": "https://github.com/bmw-software-engineering/lobster", + "commit": "main", + "file": "tests_integration/projects/basic/potato.trlc", + "line": 3 + }, + "name": "example.req_implication", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req 12345" + ], + "ref_down": [ + "cpp foo.cpp:1:implication:3", + "gtest ImplicationTest:BasicTest" + ], + "tracing_status": "OK", + "framework": "TRLC", + "kind": "Tagged_Requirement", + "text": "text: provide a utility function for logical implication", + "status": null + }, + { + "tag": "req example.req_xor", + "location": { + "kind": "file", + "file": "potato.trlc", + "line": 10, + "column": 20 + }, + "name": "example.req_xor", + "messages": [ + "tracing destination req 12345 has version 42 (expected 5)" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req 12345@5" + ], + "ref_down": [ + "simulink exclusive_or/My_Exclusive_Or" + ], + "tracing_status": "MISSING", + "framework": "TRLC", + "kind": "Tagged_Requirement", + "text": "text: provide a utility function for logical exclusive or", + "status": null + }, + { + "tag": "req example.req_nand@30", + "location": { + "kind": "file", + "file": "potato.trlc", + "line": 16, + "column": 20 + }, + "name": "example.req_nand", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req 12345@42" + ], + "ref_down": [ + "matlab nand", + "matlab nand_test::test_1" + ], + "tracing_status": "OK", + "framework": "TRLC", + "kind": "Tagged_Requirement", + "text": "text: provide a utility function for logical negated and", + "status": null + }, + { + "tag": "req example.req_nor@40", + "location": { + "kind": "file", + "file": "potato.trlc", + "line": 24, + "column": 13 + }, + "name": "example.req_nor", + "messages": [ + "missing up reference", + "missing reference to Verification Test" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [], + "ref_down": [ + "python nor.Example.helper_function", + "python nor.Example.nor" + ], + "tracing_status": "MISSING", + "framework": "TRLC", + "kind": "Requirement", + "text": "provide a utility function for logical negated or", + "status": null + }, + { + "tag": "req example.req_implies", + "location": { + "kind": "file", + "file": "potato.trlc", + "line": 30, + "column": 13 + }, + "name": "example.req_implies", + "messages": [], + "just_up": [ + "not needed" + ], + "just_down": [ + "also not needed" + ], + "just_global": [], + "tracing_status": "JUSTIFIED", + "framework": "TRLC", + "kind": "Requirement", + "text": "provide a utility function for logical implication", + "status": null + }, + { + "tag": "req example.req_important", + "location": { + "kind": "file", + "file": "potato.trlc", + "line": 42, + "column": 20 + }, + "name": "example.req_important", + "messages": [ + "missing reference to Code", + "missing reference to Verification Test" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_nor@40", + "req example.req_implication" + ], + "ref_down": [], + "tracing_status": "MISSING", + "framework": "TRLC", + "kind": "Linked_Requirement", + "text": "this is important", + "status": null + } + ], + "coverage": 50.0 + }, + { + "name": "Code", + "kind": "implementation", + "items": [ + { + "tag": "cpp foo.cpp:1:implication:3", + "location": { + "kind": "file", + "file": "foo.cpp", + "line": 3, + "column": null + }, + "name": "implication", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_implication" + ], + "ref_down": [], + "tracing_status": "OK", + "language": "C/C++", + "kind": "function" + }, + { + "tag": "cpp foo.cpp:1:exclusive_or:9", + "location": { + "kind": "file", + "file": "foo.cpp", + "line": 9, + "column": null + }, + "name": "exclusive_or", + "messages": [ + "missing up reference" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "tracing_status": "MISSING", + "language": "C/C++", + "kind": "function" + }, + { + "tag": "cpp foo.cpp:1:potato:14", + "location": { + "kind": "file", + "file": "foo.cpp", + "line": 14, + "column": null + }, + "name": "potato", + "messages": [ + "missing up reference" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "tracing_status": "MISSING", + "language": "C/C++", + "kind": "function" + }, + { + "tag": "matlab nand", + "location": { + "kind": "file", + "file": "nand.m", + "line": 1, + "column": 14 + }, + "name": "nand", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_nand" + ], + "ref_down": [], + "tracing_status": "OK", + "language": "MATLAB", + "kind": "Function" + }, + { + "tag": "matlab exclusive_or/MATLAB Function", + "location": { + "kind": "file", + "file": "exclusive_or.slx", + "line": 1, + "column": 13 + }, + "name": "exclusive_or/MATLAB Function", + "messages": [ + "missing up reference" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "tracing_status": "MISSING", + "language": "MATLAB", + "kind": "Function" + }, + { + "tag": "simulink exclusive_or", + "location": { + "kind": "file", + "file": "exclusive_or.slx", + "line": null, + "column": null + }, + "name": "exclusive_or", + "messages": [ + "missing up reference" + ], + "just_up": [], + "just_down": [], + "just_global": [], + "tracing_status": "MISSING", + "language": "Simulink", + "kind": "Block" + }, + { + "tag": "simulink exclusive_or/My_Exclusive_Or", + "location": { + "kind": "file", + "file": "exclusive_or.slx", + "line": null, + "column": null + }, + "name": "exclusive_or/My Exclusive Or", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_xor" + ], + "ref_down": [], + "tracing_status": "OK", + "language": "Simulink", + "kind": "Block" + }, + { + "tag": "python nor.trlc_reference", + "location": { + "kind": "file", + "file": "nor.py", + "line": 5, + "column": null + }, + "name": "nor.trlc_reference", + "messages": [], + "just_up": [ + "helper function" + ], + "just_down": [], + "just_global": [], + "tracing_status": "JUSTIFIED", + "language": "Python", + "kind": "Function" + }, + { + "tag": "python nor.Example.helper_function", + "location": { + "kind": "file", + "file": "nor.py", + "line": 13, + "column": null + }, + "name": "nor.Example.helper_function", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_nor" + ], + "ref_down": [], + "tracing_status": "OK", + "language": "Python", + "kind": "Method" + }, + { + "tag": "python nor.Example.nor", + "location": { + "kind": "file", + "file": "nor.py", + "line": 17, + "column": null + }, + "name": "nor.Example.nor", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_nor" + ], + "ref_down": [], + "tracing_status": "OK", + "language": "Python", + "kind": "Method" + } + ], + "coverage": 60.0 + }, + { + "name": "Verification Test", + "kind": "activity", + "items": [ + { + "tag": "gtest ImplicationTest:BasicTest", + "location": { + "kind": "file", + "file": "test.cpp", + "line": 7, + "column": null + }, + "name": "ImplicationTest:BasicTest", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_implication" + ], + "ref_down": [], + "tracing_status": "OK", + "framework": "GoogleTest", + "kind": "test", + "status": "ok" + }, + { + "tag": "matlab nand_test::test_1", + "location": { + "kind": "file", + "file": "nand_test.m", + "line": 3, + "column": 13 + }, + "name": "nand_test::test_1", + "messages": [], + "just_up": [], + "just_down": [], + "just_global": [], + "ref_up": [ + "req example.req_nand" + ], + "ref_down": [], + "tracing_status": "OK", + "framework": "MATLAB", + "kind": "Test", + "status": null + } + ], + "coverage": 100.0 + } + ], + "policy": { + "System Requirements": { + "name": "System Requirements", + "kind": "requirements", + "traces": [], + "source": [ + { + "file": "system-requirements.lobster" + } + ], + "needs_tracing_up": false, + "needs_tracing_down": true, + "breakdown_requirements": [ + [ + "Software Requirements" + ] + ] + }, + "Software Requirements": { + "name": "Software Requirements", + "kind": "requirements", + "traces": [ + "System Requirements" + ], + "source": [ + { + "file": "software-requirements.lobster" + } + ], + "needs_tracing_up": true, + "needs_tracing_down": true, + "breakdown_requirements": [ + [ + "Code" + ], + [ + "Verification Test" + ] + ] + }, + "Code": { + "name": "Code", + "kind": "implementation", + "traces": [ + "Software Requirements" + ], + "source": [ + { + "file": "cppcode.lobster" + } + ], + "needs_tracing_up": true, + "needs_tracing_down": false, + "breakdown_requirements": [] + }, + "Verification Test": { + "name": "Verification Test", + "kind": "activity", + "traces": [ + "Software Requirements" + ], + "source": [ + { + "file": "tests.lobster" + } + ], + "needs_tracing_up": true, + "needs_tracing_down": false, + "breakdown_requirements": [] + } + }, + "matrix": [] +} diff --git a/tests_system/lobster_rst_report/lobster_rst_report_system_test_case_base.py b/tests_system/lobster_rst_report/lobster_rst_report_system_test_case_base.py new file mode 100644 index 00000000..fdde75b2 --- /dev/null +++ b/tests_system/lobster_rst_report/lobster_rst_report_system_test_case_base.py @@ -0,0 +1,18 @@ +from pathlib import Path +from tests_system.lobster_rst_report.lobster_rst_report_test_runner import ( + LobsterRstReportTestRunner, +) +from tests_system.system_test_case_base import SystemTestCaseBase + + +class LobsterRstReportSystemTestCaseBase(SystemTestCaseBase): + def __init__(self, methodName): + super().__init__(methodName) + self._data_directory = Path(__file__).parents[0] / "data" + + def create_test_runner(self) -> LobsterRstReportTestRunner: + tool_name = Path(__file__).parents[0].name + test_runner = LobsterRstReportTestRunner( + self.create_temp_dir(prefix=f"test-{tool_name}-"), + ) + return test_runner diff --git a/tests_system/lobster_rst_report/lobster_rst_report_test_runner.py b/tests_system/lobster_rst_report/lobster_rst_report_test_runner.py new file mode 100644 index 00000000..8b97f005 --- /dev/null +++ b/tests_system/lobster_rst_report/lobster_rst_report_test_runner.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional +from tests_system.testrunner import TestRunner +from lobster.tools.core.rst_report.rst_report import main + + +@dataclass +class CmdArgs: + lobster_report: Optional[str] = None + out: Optional[str] = None + out_dir: Optional[str] = None + source_root: Optional[str] = None + + def as_list(self) -> List[str]: + """Returns the command line arguments as a list.""" + cmd_args = [] + if self.lobster_report is not None: + cmd_args.append(self.lobster_report) + if self.out is not None: + cmd_args.extend(["--out", self.out]) + if self.out_dir is not None: + cmd_args.extend(["--out-dir", self.out_dir]) + if self.source_root is not None: + cmd_args.extend(["--source-root", self.source_root]) + return cmd_args + + +class LobsterRstReportTestRunner(TestRunner): + """System test runner for lobster-rst-report""" + + def __init__(self, working_dir: Path): + super().__init__(main, working_dir) + self._cmd_args = CmdArgs() + + @property + def cmd_args(self) -> CmdArgs: + return self._cmd_args + + def get_tool_args(self) -> List[str]: + """Returns the command line arguments that shall be used to start + 'lobster-rst-report' under test.""" + return self._cmd_args.as_list() diff --git a/tests_system/lobster_rst_report/test_rst_report_input_file.py b/tests_system/lobster_rst_report/test_rst_report_input_file.py new file mode 100644 index 00000000..06c8b2fe --- /dev/null +++ b/tests_system/lobster_rst_report/test_rst_report_input_file.py @@ -0,0 +1,267 @@ +import unittest + +from tests_system.asserter import Asserter +from tests_system.lobster_rst_report.lobster_rst_report_system_test_case_base import ( + LobsterRstReportSystemTestCaseBase, +) + + +class RstReportInputFileTest(LobsterRstReportSystemTestCaseBase): + """Tests for input file handling by lobster-rst-report.""" + + def setUp(self): + super().setUp() + self._test_runner = self.create_test_runner() + + def test_missing_input_file_exits_nonzero(self): + # lobster-trace: rst_req.Missing_Lobster_File + self._test_runner.cmd_args.lobster_report = "does_not_exist.lobster" + self._test_runner.cmd_args.out = "out.rst" + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(2) + asserter.assertInStdErr("does_not_exist.lobster") + + def test_malformed_lobster_file_exits_with_error(self): + """A malformed .lobster file must cause a non-zero exit; the tool must + not crash with an unhandled exception.""" + # Write a file that exists but contains invalid JSON. + bad_file = self._test_runner.working_dir / "bad.lobster" + bad_file.write_text("this is not valid json", encoding="UTF-8") + self._test_runner.cmd_args.lobster_report = "bad.lobster" + self._test_runner.cmd_args.out = "out.rst" + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(1) + + def test_valid_input_single_page_creates_rst_file(self): + # lobster-trace: UseCases.RST_File_generation + # lobster-trace: rst_req.Valid_Lobster_File + # lobster-trace: rst_req.RST_Report_Single_Page + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_file = "report.rst" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out = out_file + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + asserter.assertNoStdErrText() + + output_path = self._test_runner.working_dir / out_file + self.assertTrue(output_path.exists(), f"{out_file} was not created") + + def test_valid_input_single_page_rst_content(self): + # lobster-trace: UseCases.RST_File_generation + # lobster-trace: rst_req.Valid_Lobster_File + # lobster-trace: rst_req.RST_Report_Coverage_Table + # lobster-trace: rst_req.RST_Report_Issues_List + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_file = "report.rst" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out = out_file + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + + output_path = self._test_runner.working_dir / out_file + content = output_path.read_text(encoding="UTF-8") + self.assertIn("L.O.B.S.T.E.R. Traceability Report", content) + self.assertIn("Coverage Summary", content) + self.assertIn("Issues", content) + + def test_valid_input_multi_page_creates_index_rst(self): + # lobster-trace: rst_req.Valid_Lobster_File_Multi_Page + # lobster-trace: rst_req.RST_Report_Multi_Page + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_dir = "rst_pages" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out_dir = out_dir + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + asserter.assertNoStdErrText() + + index_path = self._test_runner.working_dir / out_dir / "index.rst" + self.assertTrue(index_path.exists(), "index.rst was not created") + + def test_valid_input_multi_page_creates_level_pages(self): + # lobster-trace: rst_req.Valid_Lobster_File_Multi_Page + # lobster-trace: rst_req.RST_Report_Multi_Page + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_dir = "rst_pages" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out_dir = out_dir + + completed_process = self._test_runner.run_tool_test() + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + + out_path = self._test_runner.working_dir / out_dir + rst_files = list(out_path.glob("*.rst")) + # Should have index.rst + at least one level page + self.assertGreater( + len(rst_files), 1, "Expected index.rst and at least one level page" + ) + + def _get_single_page_content(self) -> str: + """Helper: run the tool and return the single-page RST content.""" + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_file = "report.rst" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out = out_file + completed_process = self._test_runner.run_tool_test() + self.assertEqual(completed_process.returncode, 0) + return (self._test_runner.working_dir / out_file).read_text(encoding="UTF-8") + + def test_tracing_policy_diagram_in_output(self): + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram + content = self._get_single_page_content() + # The policy diagram is always emitted as a Graphviz RST directive. + self.assertIn(".. graphviz::", content) + self.assertIn("digraph tracing_policy", content) + + def test_covered_requirements_listed(self): + # lobster-trace: UseCases.RST_Covered_Requirement_list + # lobster-trace: rst_req.RST_Report_Item_Details + content = self._get_single_page_content() + # A covered requirement with OK status (from System Requirements level) + self.assertIn("[OK]", content) + # A covered TRLC requirement that has OK status + self.assertIn("example.req\\_implication", content) + + def test_not_covered_requirements_listed(self): + # lobster-trace: UseCases.RST_Not_covered_Requirement_list + content = self._get_single_page_content() + # Missing TRLC requirements from the basic_report test data + self.assertIn("[MISSING]", content) + self.assertIn("example.req\\_xor", content) + self.assertIn("example.req\\_nor", content) + + def test_tests_covering_requirements_listed(self): + # lobster-trace: UseCases.RST_List_of_tests_covering_requirements + content = self._get_single_page_content() + # A GoogleTest item that is OK (has upward coverage) + self.assertIn("ImplicationTest:BasicTest", content) + + def test_tests_not_covering_requirements_listed(self): + # lobster-trace: UseCases.RST_List_of_tests_not_covering_requirements + content = self._get_single_page_content() + # Items with MISSING status at Code or Verification Test level + # exclusive_or has MISSING status in the test data + self.assertIn("exclusive\\_or", content) + + def test_coverage_values_in_output(self): + # lobster-trace: UseCases.RST_Coverage_in_output + # lobster-trace: rst_req.RST_Report_Coverage_Table + content = self._get_single_page_content() + # Software Requirements: 3/6 = 50.0% + self.assertIn("50.0%", content) + self.assertIn("3 of 6 items OK", content) + # Code: 6/10 = 60.0% + self.assertIn("60.0%", content) + # System Requirements: 1/1 = 100.0% + self.assertIn("100.0%", content) + + def test_findings_listed_in_output(self): + # lobster-trace: UseCases.RST_Missing_tracing_policy_violation_in_output + # lobster-trace: rst_req.RST_Report_Issues_List + content = self._get_single_page_content() + # Check that the issues list section appears + self.assertIn("Issues", content) + # The basic_report data has MISSING items — at least one must appear in the + # issues list with a finding label + self.assertIn("MISSING", content) + + def test_source_locations_in_output(self): + # lobster-trace: UseCases.RST_Source_location_in_output + # lobster-trace: UseCases.RST_Correct_Item_Data + content = self._get_single_page_content() + # Codebeamer URL for system requirement (item 666 on potato.kitten) + self.assertIn("potato.kitten/issue/666", content) + # GitHub URL for software requirements + self.assertIn("github.com/bmw-software-engineering/lobster", content) + + # ------------------------------------------------------------------ + # Multi-page content verification + # ------------------------------------------------------------------ + + def _get_multi_page_contents(self): + """Helper: run the tool in multi-page mode and return a dict of + {filename: content}.""" + self._test_runner = self.create_test_runner() + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + out_dir = "rst_pages" + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out_dir = out_dir + completed_process = self._test_runner.run_tool_test() + self.assertEqual(completed_process.returncode, 0) + out_path = self._test_runner.working_dir / out_dir + pages = {} + for rst_file in out_path.glob("*.rst"): + pages[rst_file.name] = rst_file.read_text(encoding="UTF-8") + return pages + + def test_multi_page_index_contains_toctree(self): + # lobster-trace: rst_req.RST_Report_Multi_Page + pages = self._get_multi_page_contents() + index = pages["index.rst"] + self.assertIn(".. toctree::", index) + + def test_multi_page_index_contains_doc_crossrefs(self): + # lobster-trace: rst_req.RST_Report_Multi_Page + # lobster-trace: rst_req.RST_Report_Coverage_Table + pages = self._get_multi_page_contents() + index = pages["index.rst"] + # Multi-page mode uses :doc: links in the coverage table + self.assertIn(":doc:", index) + + def test_multi_page_level_page_has_item_details(self): + # lobster-trace: rst_req.RST_Report_Item_Details + # lobster-trace: rst_req.RST_Report_Multi_Page + pages = self._get_multi_page_contents() + # Remove index.rst — the remaining files are level pages + level_pages = {k: v for k, v in pages.items() if k != "index.rst"} + self.assertGreater(len(level_pages), 0, "No level pages generated") + # At least one level page must contain items (dropdown directives) + any_items = any(".. dropdown::" in v for v in level_pages.values()) + self.assertTrue(any_items, "No items found in any level page") + # At least one level page must show coverage + any_coverage = any("Coverage:" in v for v in level_pages.values()) + self.assertTrue(any_coverage, "No coverage data in any level page") + + def test_codebeamer_link_rendered_in_output(self): + # lobster-trace: UseCases.RST_Codebeamer_Links_In_Output + # lobster-trace: rst_req.RST_Clickable_Codebeamer_Item + content = self._get_single_page_content() + # The basic_report.lobster has a codebeamer item at potato.kitten + # with item 666 and version 42 + self.assertIn("potato.kitten/issue/666?version=42", content) + # The link should be an RST anonymous hyperlink + self.assertIn("`__", content) + + def test_codebeamer_item_name_displayed(self): + # lobster-trace: rst_req.RST_Codebeamer_Item_Name + content = self._get_single_page_content() + # The codebeamer item in the test data has name "LOBSTER demo" + self.assertIn("LOBSTER demo", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_unit/lobster_rst_report/BUILD.bazel b/tests_unit/lobster_rst_report/BUILD.bazel new file mode 100644 index 00000000..593939c1 --- /dev/null +++ b/tests_unit/lobster_rst_report/BUILD.bazel @@ -0,0 +1,20 @@ +load("@rules_python//python:defs.bzl", "py_test") + +# BUILD.bazel +# gazelle:exclude __init__.py + +py_test( + name = "test_rst_report_helpers", + srcs = ["test_rst_report_helpers.py"], + deps = [ + "//lobster/tools/core/rst_report", + ], +) + +py_test( + name = "test_rst_report_renderers", + srcs = ["test_rst_report_renderers.py"], + deps = [ + "//lobster/tools/core/rst_report", + ], +) diff --git a/tests_unit/lobster_rst_report/__init__.py b/tests_unit/lobster_rst_report/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests_unit/lobster_rst_report/test_rst_report_helpers.py b/tests_unit/lobster_rst_report/test_rst_report_helpers.py new file mode 100644 index 00000000..ef2b5f7f --- /dev/null +++ b/tests_unit/lobster_rst_report/test_rst_report_helpers.py @@ -0,0 +1,321 @@ +import subprocess +import unittest +from unittest.mock import MagicMock, patch + +from lobster.tools.core.rst_report._helpers import ( + RstUtils, + ItemNaming, + TracingClassifier, + PolicyDiagramBuilder, +) +from lobster.tools.core.rst_report._renderers import _build_page_map +from lobster.common.graphviz_utils import is_dot_available +from lobster.common.location import ( + Void_Reference, + File_Reference, + Github_Reference, + Codebeamer_Reference, +) + + +# --------------------------------------------------------------------------- +# RstUtils +# --------------------------------------------------------------------------- + +class TestRstUtilsEscape(unittest.TestCase): + def test_backslash_is_escaped(self): + self.assertEqual(RstUtils.escape("a\\b"), "a\\\\b") + + def test_backtick_is_escaped(self): + self.assertEqual(RstUtils.escape("a`b"), "a\\`b") + + def test_asterisk_is_escaped(self): + self.assertEqual(RstUtils.escape("a*b"), "a\\*b") + + def test_underscore_is_escaped(self): + self.assertEqual(RstUtils.escape("a_b"), "a\\_b") + + def test_pipe_is_escaped(self): + self.assertEqual(RstUtils.escape("a|b"), "a\\|b") + + def test_plain_text_unchanged(self): + self.assertEqual(RstUtils.escape("hello world"), "hello world") + + def test_multiple_special_chars(self): + result = RstUtils.escape("a_b*c`d") + self.assertIn("\\_", result) + self.assertIn("\\*", result) + self.assertIn("\\`", result) + + +class TestRstUtilsHeading(unittest.TestCase): + def test_underline_only(self): + lines = RstUtils.heading("Hello", "=") + self.assertEqual(lines, ["Hello", "====="]) + + def test_overline_and_underline(self): + lines = RstUtils.heading("Hi", "#", overline=True) + self.assertEqual(lines, ["##", "Hi", "##"]) + + def test_heading_length_matches_text(self): + text = "Some Long Title" + lines = RstUtils.heading(text, "-") + self.assertEqual(len(lines[1]), len(text)) + + +# --------------------------------------------------------------------------- +# ItemNaming +# --------------------------------------------------------------------------- + +class TestItemNamingLevelPageName(unittest.TestCase): + def test_spaces_become_underscores(self): + self.assertEqual(ItemNaming.level_page_name("System Requirements"), "system_requirements") + + def test_hyphens_become_underscores(self): + self.assertEqual(ItemNaming.level_page_name("My-Level"), "my_level") + + def test_consecutive_underscores_collapsed(self): + result = ItemNaming.level_page_name("A B") + self.assertNotIn("__", result) + + def test_leading_trailing_underscores_stripped(self): + result = ItemNaming.level_page_name(" level ") + self.assertFalse(result.startswith("_")) + self.assertFalse(result.endswith("_")) + + def test_empty_like_input_returns_level(self): + self.assertEqual(ItemNaming.level_page_name("___"), "level") + + def test_mixed_special_chars(self): + result = ItemNaming.level_page_name("A/B (C)") + self.assertNotIn("/", result) + self.assertNotIn("(", result) + self.assertNotIn(")", result) + + +class TestBuildPageMap(unittest.TestCase): + """Tests for _build_page_map collision resolution.""" + + def _make_report(self, level_names): + """Return a minimal mock with just the config attribute.""" + report = MagicMock() + report.config = {name: MagicMock() for name in level_names} + return report + + def test_unique_names_produce_unique_stems(self): + report = self._make_report(["System Requirements", "Code"]) + page_map = _build_page_map(report) + stems = list(page_map.values()) + self.assertEqual(len(stems), len(set(stems))) + + def test_duplicate_slugs_are_disambiguated(self): + # "A B" and "A-B" both collapse to "a_b" + report = self._make_report(["A B", "A-B"]) + page_map = _build_page_map(report) + stems = list(page_map.values()) + self.assertEqual(len(stems), len(set(stems)), "Duplicate stems not disambiguated") + + +# --------------------------------------------------------------------------- +# TracingClassifier +# --------------------------------------------------------------------------- + +class TestTracingClassifierCategorize(unittest.TestCase): + def test_up_reference_message(self): + down, up, gen = TracingClassifier.categorize(["missing up reference"]) + self.assertIn("missing up reference", up) + self.assertEqual(down, []) + self.assertEqual(gen, []) + + def test_down_reference_message(self): + down, up, gen = TracingClassifier.categorize(["missing down reference"]) + self.assertIn("missing down reference", down) + self.assertEqual(up, []) + self.assertEqual(gen, []) + + def test_missing_reference_to_message(self): + msg = "missing reference to Verification Test" + down, _, _ = TracingClassifier.categorize([msg]) + self.assertIn(msg, down) + + def test_tracing_destination_message(self): + msg = "tracing destination req X has version 2 (expected 1)" + down, _, _ = TracingClassifier.categorize([msg]) + self.assertIn(msg, down) + + def test_unknown_tracing_target(self): + msg = "unknown tracing target req example.foo" + down, _, _ = TracingClassifier.categorize([msg]) + self.assertIn(msg, down) + + def test_general_message(self): + msg = "some unrecognised error" + _, _, gen = TracingClassifier.categorize([msg]) + self.assertIn(msg, gen) + + +class TestTracingClassifierIssueTag(unittest.TestCase): + def test_version_mismatch(self): + self.assertEqual( + TracingClassifier.issue_tag("tracing destination X has version 2 (expected 1)"), + "version mismatch", + ) + + def test_unknown_target(self): + tag = TracingClassifier.issue_tag("unknown tracing target req foo") + self.assertIn("unknown target", tag) + + def test_missing_up_reference(self): + self.assertEqual( + TracingClassifier.issue_tag("missing up reference"), + "no upward trace", + ) + + def test_missing_reference_to(self): + tag = TracingClassifier.issue_tag("missing reference to Verification Test") + self.assertIn("no trace to", tag) + + def test_missing_down_reference(self): + self.assertEqual( + TracingClassifier.issue_tag("missing down reference"), + "no downward trace", + ) + + def test_fallback_escapes_original(self): + tag = TracingClassifier.issue_tag("some_underscored_message") + self.assertIn("\\_", tag) + + +# --------------------------------------------------------------------------- +# PolicyDiagramBuilder +# --------------------------------------------------------------------------- + +class TestPolicyDiagramBuilderDotEscape(unittest.TestCase): + def test_double_quote_escaped(self): + self.assertEqual(PolicyDiagramBuilder.dot_escape('a"b'), 'a\\"b') + + def test_backslash_escaped(self): + self.assertEqual(PolicyDiagramBuilder.dot_escape("a\\b"), "a\\\\b") + + def test_plain_text_unchanged(self): + self.assertEqual(PolicyDiagramBuilder.dot_escape("hello"), "hello") + + +# --------------------------------------------------------------------------- +# is_dot_available +# --------------------------------------------------------------------------- + +class TestIsDotAvailable(unittest.TestCase): + def test_returns_true_when_dot_succeeds(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = None + self.assertTrue(is_dot_available()) + + def test_returns_false_when_dot_not_found(self): + with patch("subprocess.run", side_effect=FileNotFoundError): + self.assertFalse(is_dot_available()) + + def test_returns_false_when_dot_times_out(self): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("dot", 5)): + self.assertFalse(is_dot_available()) + + def test_explicit_dot_path_is_used(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = None + is_dot_available(dot="/usr/bin/dot") + called_cmd = mock_run.call_args[0][0] + self.assertEqual(called_cmd[0], "/usr/bin/dot") + + def test_returns_false_when_dot_returns_nonzero(self): + with patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError(1, "dot"), + ): + self.assertFalse(is_dot_available()) + + +# --------------------------------------------------------------------------- +# ItemNaming.location_link +# --------------------------------------------------------------------------- + +class TestLocationLink(unittest.TestCase): + # lobster-trace: UseCases.RST_Codebeamer_Links_In_Output + # lobster-trace: UseCases.RST_Source_location_in_output + + def test_void_reference_returns_unknown(self): + loc = Void_Reference() + self.assertEqual(ItemNaming.location_link(loc), "unknown location") + + def test_file_reference_no_source_root(self): + loc = File_Reference("src/main.cpp", line=42) + result = ItemNaming.location_link(loc) + self.assertIn("src/main.cpp", result) + self.assertIn("`__", result) # anonymous hyperlink + self.assertIn("", result) + + def test_file_reference_with_source_root(self): + loc = File_Reference("src/main.cpp", line=10) + result = ItemNaming.location_link(loc, source_root="https://example.com/") + self.assertIn("https://example.com/src/main.cpp", result) + self.assertIn("`__", result) + + def test_file_reference_with_line_number(self): + loc = File_Reference("foo.py", line=7) + result = ItemNaming.location_link(loc) + self.assertIn("foo.py:7", result) + + def test_file_reference_without_line_number(self): + loc = File_Reference("foo.py") + result = ItemNaming.location_link(loc) + self.assertIn("foo.py", result) + self.assertNotIn("foo.py:", result) + + def test_github_reference_with_line(self): + loc = Github_Reference( + "https://github.com/org/repo", "src/main.cpp", 42, "abc123" + ) + result = ItemNaming.location_link(loc) + self.assertIn("github.com/org/repo/blob/abc123/src/main.cpp", result) + self.assertIn("#L42", result) + self.assertIn("`__", result) + + def test_github_reference_without_line(self): + loc = Github_Reference( + "https://github.com/org/repo", "README.md", None, "abc123" + ) + result = ItemNaming.location_link(loc) + self.assertIn("github.com/org/repo/blob/abc123/README.md", result) + self.assertNotIn("#L", result) + + def test_codebeamer_reference_without_version(self): + loc = Codebeamer_Reference("https://cb.example.com", 1, 999) + result = ItemNaming.location_link(loc) + self.assertIn("cb.example.com/issue/999", result) + self.assertNotIn("?version=", result) + self.assertIn("`__", result) + + def test_codebeamer_reference_with_version(self): + loc = Codebeamer_Reference("https://cb.example.com", 1, 999, version=5) + result = ItemNaming.location_link(loc) + self.assertIn("cb.example.com/issue/999?version=5", result) + + def test_codebeamer_reference_with_name(self): + loc = Codebeamer_Reference( + "https://cb.example.com", 1, 999, name="My Requirement" + ) + result = ItemNaming.location_link(loc) + self.assertIn("My Requirement", result) + self.assertIn("cb item 999", result) + + def test_unknown_location_type_returns_escaped_text(self): + """A location type not handled by any branch should be escaped.""" + class CustomLocation: # pylint: disable=too-few-public-methods + def __str__(self): + return "custom_loc_42" + loc = CustomLocation() + result = ItemNaming.location_link(loc) + self.assertIn("custom\\_loc\\_42", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_unit/lobster_rst_report/test_rst_report_renderers.py b/tests_unit/lobster_rst_report/test_rst_report_renderers.py new file mode 100644 index 00000000..4c179f76 --- /dev/null +++ b/tests_unit/lobster_rst_report/test_rst_report_renderers.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# SPDX-License-Identifier: AGPL-3.0-or-later +import unittest +from unittest.mock import MagicMock +from lobster.common.items import Tracing_Status, Activity +from lobster.common.location import Void_Reference +from lobster.common.report import Report +from lobster.tools.core.rst_report._helpers import ItemNaming +from lobster.tools.core.rst_report._renderers import ( + ItemCardBuilder, + LevelSectionBuilder, + CoverageGridBuilder, + IssuesListBuilder, +) + + +def _make_item(status): + item = MagicMock() + item.__class__ = Activity + item.tag = MagicMock() + item.tag.hash.return_value = "abc123" + item.tracing_status = status + item.name = "test.item" + item.messages = [] + item.ref_down = [] + item.ref_up = [] + item.just_global = [] + item.just_up = [] + item.just_down = [] + item.framework = "TestFramework" + item.kind = "test" + item.location = MagicMock() + item.location.__class__ = Void_Reference + item.location.sorting_key.return_value = (0, 0) + return item + + +def _make_report(items=None): + report = MagicMock(spec=Report) + report.items = items if items is not None else {} + return report + + +class TestItemCardBuilderDropdownState(unittest.TestCase): + def _build(self, status): + return "\n".join(ItemCardBuilder(_make_item(status), _make_report()).build()) + + def test_partial_item_renders_open(self): + text = self._build(Tracing_Status.PARTIAL) + self.assertIn("[PARTIAL]", text) + self.assertIn(":open:", text) + + def test_error_item_renders_open(self): + text = self._build(Tracing_Status.ERROR) + self.assertIn("[ERROR]", text) + self.assertIn(":open:", text) + + def test_missing_item_renders_open(self): + text = self._build(Tracing_Status.MISSING) + self.assertIn("[MISSING]", text) + self.assertIn(":open:", text) + + def test_ok_item_renders_closed(self): + text = self._build(Tracing_Status.OK) + self.assertIn("[OK]", text) + self.assertNotIn(":open:", text) + + def test_justified_item_renders_closed(self): + text = self._build(Tracing_Status.JUSTIFIED) + self.assertIn("[JUSTIFIED]", text) + self.assertNotIn(":open:", text) + + +class TestItemCardBuilderAnchor(unittest.TestCase): + def test_anchor_label_contains_hash(self): + item = _make_item(Tracing_Status.OK) + item.tag.hash.return_value = "deadbeef" + text = "\n".join(ItemCardBuilder(item, _make_report()).build()) + self.assertIn("lobster-item-deadbeef", text) + + +class TestItemCardBuilderVoidLocation(unittest.TestCase): + def test_void_location_renders_unknown_location(self): + item = _make_item(Tracing_Status.OK) + text = "\n".join(ItemCardBuilder(item, _make_report()).build()) + self.assertIn("unknown location", text) + + +class TestItemCardBuilderRefResolution(unittest.TestCase): + def test_unresolved_down_ref_is_annotated(self): + item = _make_item(Tracing_Status.MISSING) + unresolved = MagicMock() + unresolved.key.return_value = "req example.unknown" + item.ref_down = [unresolved] + text = "\n".join(ItemCardBuilder(item, _make_report()).build()) + self.assertIn("(unresolved)", text) + self.assertIn("req example.unknown", text) + + def test_resolved_down_ref_produces_ref_link(self): + target = _make_item(Tracing_Status.OK) + target.name = "example.req_foo" + target.tag.hash.return_value = "feed1234" + item = _make_item(Tracing_Status.MISSING) + ref = MagicMock() + ref.key.return_value = "req example.req_foo" + item.ref_down = [ref] + report = _make_report(items={"req example.req_foo": target}) + text = "\n".join(ItemCardBuilder(item, report).build()) + self.assertIn(":ref:", text) + self.assertIn("lobster-item-feed1234", text) + self.assertNotIn("(unresolved)", text) + + +class TestLocationLinkVoidReference(unittest.TestCase): + def test_void_reference_returns_unknown_location(self): + void_loc = MagicMock(spec=Void_Reference) + result = ItemNaming.location_link(void_loc) + self.assertEqual(result, "unknown location") + + +class TestLevelSectionBuilderEdgeCases(unittest.TestCase): + def test_empty_items_list_returns_no_items_message(self): + lines = LevelSectionBuilder([], _make_report()).build() + self.assertIn("No items recorded at this level.", lines) + + def test_issues_before_ok_items(self): + ok_item = _make_item(Tracing_Status.OK) + ok_item.name = "ok.item" + ok_item.tag.hash.return_value = "ok000000" + issue_item = _make_item(Tracing_Status.MISSING) + issue_item.name = "missing.item" + issue_item.tag.hash.return_value = "miss0000" + lines = LevelSectionBuilder([ok_item, issue_item], _make_report()).build() + text = "\n".join(lines) + ok_pos = text.find("lobster-item-ok000000") + miss_pos = text.find("lobster-item-miss0000") + self.assertGreater(ok_pos, miss_pos, "Issue items must precede OK items") + + def test_all_ok_items_renders_without_error(self): + items = [_make_item(Tracing_Status.OK) for _ in range(3)] + for i, it in enumerate(items): + it.tag.hash.return_value = f"ok{i:06d}" + it.name = f"item_{i}" + lines = LevelSectionBuilder(items, _make_report()).build() + self.assertTrue(len(lines) > 0) + + +# --------------------------------------------------------------------------- +# CoverageGridBuilder +# --------------------------------------------------------------------------- + +def _make_coverage(level, items_count, ok_count, coverage_pct): + cov = MagicMock() + cov.level = level + cov.items = items_count + cov.ok = ok_count + cov.coverage = coverage_pct + return cov + + +def _make_level_config(name, kind, traces=None): + lv = MagicMock() + lv.name = name + lv.kind = kind + lv.traces = traces or [] + return lv + + +def _make_report_with_coverage(levels): + """Create a report mock with config and coverage data. + + Args: + levels: list of (name, kind, items, ok, coverage, traces) tuples. + """ + report = MagicMock(spec=Report) + config = {} + coverage = {} + for name, kind, items_count, ok_count, cov_pct, traces in levels: + config[name] = _make_level_config(name, kind, traces) + coverage[name] = _make_coverage(name, items_count, ok_count, cov_pct) + report.config = config + report.coverage = coverage + report.items = {} + return report + + +class TestCoverageGridBuilder(unittest.TestCase): + # lobster-trace: UseCases.RST_Coverage_in_output + + def test_coverage_table_contains_headers(self): + report = _make_report_with_coverage([ + ("Requirements", "requirements", 10, 8, 80.0, []), + ]) + lines = CoverageGridBuilder(report).build(lambda n: f"REF({n})") + text = "\n".join(lines) + self.assertIn("Category", text) + self.assertIn("Coverage", text) + self.assertIn("OK Items", text) + self.assertIn("Total Items", text) + + def test_coverage_table_contains_level_data(self): + report = _make_report_with_coverage([ + ("Software Reqs", "requirements", 20, 15, 75.0, []), + ]) + lines = CoverageGridBuilder(report).build(lambda n: f"REF({n})") + text = "\n".join(lines) + self.assertIn("REF(Software Reqs)", text) + self.assertIn("75.0%", text) + self.assertIn("15", text) + self.assertIn("20", text) + + def test_coverage_calls_ref_fn_for_each_level(self): + report = _make_report_with_coverage([ + ("Reqs", "requirements", 5, 5, 100.0, []), + ("Code", "implementation", 3, 2, 66.7, []), + ]) + called_with = [] + def ref_fn(n): + called_with.append(n) + return f"REF({n})" + CoverageGridBuilder(report).build(ref_fn) + self.assertIn("Reqs", called_with) + self.assertIn("Code", called_with) + + def test_coverage_grid_contains_graphviz_directive(self): + report = _make_report_with_coverage([ + ("Reqs", "requirements", 5, 5, 100.0, []), + ]) + lines = CoverageGridBuilder(report).build(lambda n: n) + text = "\n".join(lines) + self.assertIn(".. graphviz::", text) + self.assertIn("digraph tracing_policy", text) + + +# --------------------------------------------------------------------------- +# IssuesListBuilder +# --------------------------------------------------------------------------- + +class TestIssuesListBuilder(unittest.TestCase): + # lobster-trace: UseCases.RST_Missing_tracing_policy_violation_in_output + + def test_all_ok_items_shows_no_issues(self): + report = _make_report() + ok_item = _make_item(Tracing_Status.OK) + ok_item.name = "good.item" + report.items = {"req good.item": ok_item} + lines = IssuesListBuilder(report).build() + text = "\n".join(lines) + self.assertIn("No traceability issues found.", text) + + def test_missing_item_appears_in_issues(self): + report = _make_report() + bad_item = _make_item(Tracing_Status.MISSING) + bad_item.name = "bad.item" + bad_item.messages = ["missing up reference"] + report.items = {"req bad.item": bad_item} + lines = IssuesListBuilder(report).build() + text = "\n".join(lines) + self.assertIn("MISSING", text) + self.assertIn("bad.item", text) + self.assertNotIn("No traceability issues found.", text) + + def test_issue_tag_formatting(self): + report = _make_report() + item = _make_item(Tracing_Status.MISSING) + item.name = "req.test" + item.messages = ["missing reference to Verification Test"] + report.items = {"req req.test": item} + lines = IssuesListBuilder(report).build() + text = "\n".join(lines) + self.assertIn("no trace to:", text) + + def test_justified_item_not_in_issues(self): + report = _make_report() + just_item = _make_item(Tracing_Status.JUSTIFIED) + just_item.name = "just.item" + just_item.messages = [] + report.items = {"req just.item": just_item} + lines = IssuesListBuilder(report).build() + text = "\n".join(lines) + self.assertIn("No traceability issues found.", text) + + +if __name__ == "__main__": + unittest.main() From c8f505bfaf4de2e7c894c8a963200d1191626966 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Tue, 23 Jun 2026 13:32:38 +0200 Subject: [PATCH 2/3] fix review changes --- CHANGELOG.md | 4 +- documentation/manual-lobster_rst_report.md | 43 ++ lobster-rst-report.py | 2 +- lobster/tools/core/rst_report/_helpers.py | 25 +- lobster/tools/core/rst_report/_renderers.py | 10 +- .../rst_report/requirements/requirements.trlc | 23 +- lobster/tools/core/rst_report/rst_report.py | 67 ++- .../projects/sphinx_rst_report/conf.py | 2 +- .../sphinx_rst_report/test_sphinx_build.py | 4 +- tests_system/lobster_rst_report/BUILD.bazel | 1 + .../lobster_rst_report/data/BUILD.bazel | 1 + .../data/expected_single_page.rst | 529 ++++++++++++++++++ .../data/expected_single_page_no_dot.rst | 517 +++++++++++++++++ .../test_rst_report_input_file.py | 148 ++++- tests_system/tests_utils/BUILD.bazel | 1 + .../tests_utils/update_version_in_rst_file.py | 50 ++ 16 files changed, 1395 insertions(+), 32 deletions(-) create mode 100644 tests_system/lobster_rst_report/data/expected_single_page.rst create mode 100644 tests_system/lobster_rst_report/data/expected_single_page_no_dot.rst create mode 100644 tests_system/tests_utils/update_version_in_rst_file.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a6ae5a..f72ef689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ - Bazel integration via `subrule_lobster_rst_report` (opt-in). * Refactored `is_dot_available()` into `lobster.common.graphviz_utils` - (shared between `lobster-html-report` and `lobster-rst-report`).* `trlc bazel dep`: update to trlc==2.0.5 + (shared between `lobster-html-report` and `lobster-rst-report`). + + * `trlc bazel dep`: update to trlc==2.0.5 * Fixed wrong links in [README](README.md). diff --git a/documentation/manual-lobster_rst_report.md b/documentation/manual-lobster_rst_report.md index c265c1f4..66d3438c 100644 --- a/documentation/manual-lobster_rst_report.md +++ b/documentation/manual-lobster_rst_report.md @@ -45,6 +45,44 @@ lobster-rst-report [LOBSTER_REPORT] [--out FILE | --out-dir DIR] [--source-root `--out` and `--out-dir` are mutually exclusive. +### Choosing `--source-root` + +File references in a `.lobster` report are relative to the workspace root +(e.g. `src/module/foo.cpp`). `--source-root` turns those paths into +clickable URLs by prepending a prefix. A trailing `/` is optional — if it is +missing, one is added automatically (so both `../../` and `../..` work). + +**Example 1 — GitHub permalink** (links point to a specific branch/commit +on GitHub): + +```bash +lobster-rst-report report.lobster \ + --out-dir docs/traceability/ \ + --source-root "https://github.com/org/repo/blob/main/" +# src/module/foo.cpp → https://github.com/org/repo/blob/main/src/module/foo.cpp +``` + +**Example 2 — Relative local link** (links point to source files served +alongside the HTML documentation): + +``` +project/ + src/module/foo.cpp ← file reference: "src/module/foo.cpp" + docs/ + conf.py + traceability/ ← --out-dir target, two levels below project root +``` + +```bash +lobster-rst-report report.lobster \ + --out-dir docs/traceability/ \ + --source-root "../../" +# src/module/foo.cpp → ../../src/module/foo.cpp (relative to the RST file) +``` + +If `--source-root` is omitted, file references appear as plain text paths +without hyperlinks. + ### Example ```bash @@ -63,6 +101,11 @@ Your Sphinx `conf.py` must load the required extensions: extensions = ["sphinx_design", "sphinx.ext.graphviz"] ``` +> **Note:** `project`, `author`, and `copyright` are standard Sphinx +> settings that you configure for your own documentation project. +> `lobster-rst-report` generates RST content only — it does not create or +> modify `conf.py`. Each project sets these values independently. + For multi-page mode, include the generated `index.rst` via a `toctree` in your main documentation: diff --git a/lobster-rst-report.py b/lobster-rst-report.py index 8249f975..9b5a6ace 100644 --- a/lobster-rst-report.py +++ b/lobster-rst-report.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # LOBSTER - Lightweight Open BMW Software Traceability Evidence Report -# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as diff --git a/lobster/tools/core/rst_report/_helpers.py b/lobster/tools/core/rst_report/_helpers.py index f615c0da..fc437827 100644 --- a/lobster/tools/core/rst_report/_helpers.py +++ b/lobster/tools/core/rst_report/_helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx -# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -180,7 +180,8 @@ def location_link(location, source_root: str = "") -> str: # lobster-trace: UseCases.Item_GitHub_Source # lobster-trace: rst_req.RST_Source_Root_Prefix if isinstance(location, File_Reference): - href = source_root + location.filename if source_root else location.filename + href = (source_root.rstrip("/") + "/" + location.filename + if source_root else location.filename) return f"`{e(location.to_string())} <{href}>`__" # lobster-trace: UseCases.Item_GitHub_Source @@ -341,21 +342,37 @@ def dot_escape(text: str) -> str: return text.replace("\\", "\\\\").replace('"', '\\"') @classmethod - def build(cls, report: Report, indent: int = 0) -> list: + def build(cls, report: Report, indent: int = 0, + dot_available: bool = True) -> list: """Return RST lines for a ``.. graphviz::`` tracing-policy diagram. + When *dot_available* is ``False`` (e.g. Graphviz is not installed), a + ``.. note::`` block is emitted instead so the HTML page remains valid. + Args: report: The loaded LOBSTER report whose ``config`` provides level names, kinds, and tracing relationships. indent: Number of leading spaces to prepend to each line. Use a non-zero value when embedding inside nested RST directives such as ``.. grid-item::``. + dot_available: Whether the Graphviz ``dot`` executable is + available. Pass the return value of + :func:`~lobster.common.graphviz_utils.is_dot_available`. Returns: A list of RST lines ending with a blank string. """ - # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram indent_str = " " * indent + if not dot_available: + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram_Fallback + return [ + f"{indent_str}.. note::", + "", + f"{indent_str} Tracing policy diagram omitted —" + f" Graphviz (dot) is not installed.", + "", + ] + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram nested_indent = indent_str + " " out = [] out.append(f"{indent_str}.. graphviz::") diff --git a/lobster/tools/core/rst_report/_renderers.py b/lobster/tools/core/rst_report/_renderers.py index 7fd4d5b6..ae2b72ac 100644 --- a/lobster/tools/core/rst_report/_renderers.py +++ b/lobster/tools/core/rst_report/_renderers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx -# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -356,13 +356,15 @@ def __init__(self, report: Report): """ self._report = report - def build(self, ref_fn) -> list: + def build(self, ref_fn, dot_available: bool = True) -> list: """Return RST lines for the coverage grid. Args: ref_fn: A callable ``(level_name: str) -> str`` returning an RST cross-reference for the given level. Use ``:ref:`` links for single-page output and ``:doc:`` links for multi-page output. + dot_available: Whether the Graphviz ``dot`` executable is + available. Forwarded to :class:`PolicyDiagramBuilder`. Returns: A list of RST lines ending with a blank string. @@ -392,7 +394,9 @@ def build(self, ref_fn) -> list: lines.append(f" - {data.items}") lines.append("") lines += [" .. grid-item::", " :columns: 12 12 5 5", ""] - lines += PolicyDiagramBuilder.build(self._report, indent=6) + lines += PolicyDiagramBuilder.build( + self._report, indent=6, dot_available=dot_available + ) return lines diff --git a/lobster/tools/core/rst_report/requirements/requirements.trlc b/lobster/tools/core/rst_report/requirements/requirements.trlc index 229ba536..e32b1bf1 100644 --- a/lobster/tools/core/rst_report/requirements/requirements.trlc +++ b/lobster/tools/core/rst_report/requirements/requirements.trlc @@ -3,11 +3,32 @@ import req req.System_Requirement RST_Report_Tracing_Policy_Diagram { description = ''' - The generated RST report SHALL include a Graphviz diagram + IF the Graphviz "dot" executable is available, + THEN the generated RST report SHALL include a Graphviz diagram that visualises the tracing policy used to produce the report. ''' } +req.System_Requirement RST_Report_Tracing_Policy_Diagram_Fallback { + description = ''' + IF the Graphviz "dot" executable is not available, + THEN the generated RST report SHALL omit the tracing policy diagram + and instead include a note stating that the diagram was omitted + because Graphviz is not installed. + ''' +} + +req.System_Requirement RST_Report_Header { + description = ''' + The generated RST report SHALL begin with a header that states + the analyzed git commit hash and the LOBSTER version used to produce + the report. + IF the analyzed commit cannot be determined (e.g. git is not installed + or the working directory is not a git repository), + THEN the header SHALL display "(unknown)" in place of the commit hash. + ''' +} + req.System_Requirement RST_Report_Coverage_Table { description = ''' The generated RST report SHALL include a coverage summary table diff --git a/lobster/tools/core/rst_report/rst_report.py b/lobster/tools/core/rst_report/rst_report.py index 899abe84..034d5a0b 100644 --- a/lobster/tools/core/rst_report/rst_report.py +++ b/lobster/tools/core/rst_report/rst_report.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx -# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -36,7 +36,7 @@ import os import argparse -from datetime import datetime, timezone +import subprocess from typing import Dict, Optional, Sequence from lobster.common.version import LOBSTER_VERSION @@ -57,6 +57,29 @@ ) +def _get_git_commit() -> str: + """Return the HEAD commit hash of the current repository, or ``'(unknown)'``. + + Falls back gracefully when git is not installed or the current directory is + not inside a git repository, so that the tool remains usable outside of + version-controlled environments. + """ + # lobster-trace: rst_req.RST_Report_Header + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="UTF-8", + check=True, + timeout=5, + ) + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired, + subprocess.CalledProcessError, OSError): + return "(unknown)" + + # --------------------------------------------------------------------------- # Single-page output # --------------------------------------------------------------------------- @@ -84,13 +107,22 @@ def write_rst(report: Report, source_root: str = "") -> str: Returns: The complete RST document as a string (ending with a newline). """ + dot_available = is_dot_available() + if not dot_available: + print( + "warning: dot utility not found, report will not include " + "the tracing policy visualisation" + ) + print("> please install Graphviz (https://graphviz.org)") + commit = _get_git_commit() + lines = [] title = "L.O.B.S.T.E.R. Traceability Report" lines += RstUtils.heading(title, "#", overline=True) lines.append("") - now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - lines.append(f"| Generated: {now}") + # lobster-trace: rst_req.RST_Report_Header + lines.append(f"| Analyzed commit: {commit}") lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") lines.append("") @@ -101,7 +133,7 @@ def write_rst(report: Report, source_root: str = "") -> str: def ref_fn(n): return f":ref:`{RstUtils.escape(n)} <{ItemNaming.level_label(n)}>`" - lines += CoverageGridBuilder(report).build(ref_fn) + lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available) # Issues summary (rubric = not a TOC entry) lines.append(".. rubric:: Issues") @@ -218,12 +250,21 @@ def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]: pages[f"{stem}.rst"] = "\n".join(lines) + "\n" # -- Index page -- + dot_available = is_dot_available() + if not dot_available: + print( + "warning: dot utility not found, report will not include " + "the tracing policy visualisation" + ) + print("> please install Graphviz (https://graphviz.org)") + commit = _get_git_commit() + lines = [] title = "L.O.B.S.T.E.R. Traceability Report" lines += RstUtils.heading(title, "#", overline=True) lines.append("") - now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - lines.append(f"| Generated: {now}") + # lobster-trace: rst_req.RST_Report_Header + lines.append(f"| Analyzed commit: {commit}") lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") lines.append("") @@ -234,7 +275,7 @@ def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]: def ref_fn(n): return f":doc:`{RstUtils.escape(n)} <{page_map[n]}>`" - lines += CoverageGridBuilder(report).build(ref_fn) + lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available) # Issues list (rubric = not a TOC entry) lines.append(".. rubric:: Issues") @@ -323,7 +364,8 @@ def __init__(self): "--source-root", default="", help="prefix prepended to file reference URLs, e.g. a relative " - "path from the RST output location back to the workspace root", + "path from the RST output location back to the workspace root; " + "a trailing '/' is optional and will be added automatically", ) def _run_impl(self, options: argparse.Namespace) -> int: @@ -350,13 +392,6 @@ def _run_impl(self, options: argparse.Namespace) -> int: err.dump() return 1 - if not is_dot_available(): - print( - "warning: dot utility not found, report will not include " - "the tracing policy visualisation" - ) - print("> please install Graphviz (https://graphviz.org)") - # lobster-trace: UseCases.RST_Output # lobster-trace: rst_req.RST_Report_Multi_Page # lobster-trace: rst_req.Valid_Lobster_File_Multi_Page diff --git a/tests_integration/projects/sphinx_rst_report/conf.py b/tests_integration/projects/sphinx_rst_report/conf.py index 9f82e68e..64a730d9 100644 --- a/tests_integration/projects/sphinx_rst_report/conf.py +++ b/tests_integration/projects/sphinx_rst_report/conf.py @@ -3,7 +3,7 @@ from datetime import datetime project = "LOBSTER Traceability Report" -author = "BMW Software Engineering" +author = "Bayerische Motoren Werke Aktiengesellschaft (BMW AG)" copyright = f"{datetime.now().year}, {author}" # No special extensions needed; :ref: is a built-in Sphinx role. diff --git a/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py b/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py index 4d9597e3..3e42cdd7 100644 --- a/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py +++ b/tests_integration/projects/sphinx_rst_report/test_sphinx_build.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # # LOBSTER - Lightweight Open BMW Software Traceability Evidence Report -# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -60,7 +60,7 @@ def setUp(self): else: self._project_dir = Path(__file__).parent self._lobster_file = ( - Path(__file__).parents[2] + Path(__file__).parents[3] / "tests_system" / "lobster_rst_report" / "data" diff --git a/tests_system/lobster_rst_report/BUILD.bazel b/tests_system/lobster_rst_report/BUILD.bazel index 0b3fa572..7222727e 100644 --- a/tests_system/lobster_rst_report/BUILD.bazel +++ b/tests_system/lobster_rst_report/BUILD.bazel @@ -28,5 +28,6 @@ py_test( ":lobster_rst_report", "//lobster/tools/core/rst_report", "//tests_system", + "//tests_system/tests_utils", ], ) diff --git a/tests_system/lobster_rst_report/data/BUILD.bazel b/tests_system/lobster_rst_report/data/BUILD.bazel index bdb7bf78..0b6c56d9 100644 --- a/tests_system/lobster_rst_report/data/BUILD.bazel +++ b/tests_system/lobster_rst_report/data/BUILD.bazel @@ -2,6 +2,7 @@ filegroup( name = "rst_report_data_system", srcs = glob([ "*.lobster", + "*.rst", ]), visibility = ["//visibility:public"], ) diff --git a/tests_system/lobster_rst_report/data/expected_single_page.rst b/tests_system/lobster_rst_report/data/expected_single_page.rst new file mode 100644 index 00000000..f9adb618 --- /dev/null +++ b/tests_system/lobster_rst_report/data/expected_single_page.rst @@ -0,0 +1,529 @@ +################################## +L.O.B.S.T.E.R. Traceability Report +################################## + +| Analyzed commit: aaaa1111bbbb2222cccc3333dddd4444eeee5555 +| LOBSTER Version: 1.0.4-dev + +.. rubric:: Coverage Summary + +.. grid:: 1 1 2 2 + :gutter: 3 + + .. grid-item:: + :columns: 12 12 7 7 + + .. list-table:: + :header-rows: 1 + :widths: 35 15 15 15 + + * - Category + - Coverage + - OK Items + - Total Items + * - :ref:`System Requirements ` + - 100.0% + - 1 + - 1 + * - :ref:`Software Requirements ` + - 50.0% + - 3 + - 6 + * - :ref:`Code ` + - 60.0% + - 6 + - 10 + * - :ref:`Verification Test ` + - 100.0% + - 2 + - 2 + + .. grid-item:: + :columns: 12 12 5 5 + + .. graphviz:: + + digraph tracing_policy { + rankdir=TB; + node [shape=box, style=filled, fontname="Helvetica", margin="0.3,0.1"]; + edge [arrowhead=open]; + + "System Requirements" [fillcolor="#2196F3", fontcolor="white"]; + "Software Requirements" [fillcolor="#2196F3", fontcolor="white"]; + "Code" [fillcolor="#4CAF50", fontcolor="white"]; + "Verification Test" [fillcolor="#FF9800", fontcolor="white"]; + "Software Requirements" -> "System Requirements"; + "Code" -> "Software Requirements"; + "Verification Test" -> "Software Requirements"; + } + +.. rubric:: Issues + +* [MISSING — no upward trace] :ref:`exclusive\_or ` +* [MISSING — no upward trace] :ref:`exclusive\_or/MATLAB Function ` +* [MISSING — no upward trace] :ref:`exclusive\_or ` +* [MISSING — no upward trace] :ref:`potato ` +* [MISSING — version mismatch] :ref:`example.req\_xor ` +* [MISSING — no upward trace] :ref:`example.req\_nor ` +* [MISSING — no trace to: Verification Test] :ref:`example.req\_nor ` +* [MISSING — no trace to: Code] :ref:`example.req\_important ` +* [MISSING — no trace to: Verification Test] :ref:`example.req\_important ` + +Requirements and Specification +============================== + +.. _lobster-level-system-requirements: + +System Requirements +------------------- + +**Coverage:** 100.0% (1 of 1 items OK) + +.. _lobster-item-d499f1e2ff6c8662012d4c4bfe35060a5eda7b6c: + +.. dropdown:: [OK] codebeamer Functional requirement LOBSTER demo + :class-title: sd-bg-success sd-text-white + + **Status:** Potato + + .. pull-quote:: + + Provide a nice demonstration of LOBSTER through four examples + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`example.req\_implication ` + * :ref:`example.req\_xor ` + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `cb item 666 'LOBSTER demo' `__ + +.. _lobster-level-software-requirements: + +Software Requirements +--------------------- + +**Coverage:** 50.0% (3 of 6 items OK) + +.. _lobster-item-5c0948f96e743a0c3ab3c14ecac0f5d10609f324: + +.. dropdown:: [MISSING] TRLC Tagged\_requirement example.req\_xor + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical exclusive or + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`exclusive\_or/My Exclusive Or ` + + .. card:: + :class-card: lobster-issue-card + + * tracing destination req 12345 has version 42 (expected 5) + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:10:20 `__ + +.. _lobster-item-714c0c22dee57a69c9425cff0c0bd4ca9ebe5d1f: + +.. dropdown:: [MISSING] TRLC Requirement example.req\_nor + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + provide a utility function for logical negated or + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`nor.Example.helper\_function ` + * :ref:`nor.Example.nor ` + + .. card:: + :class-card: lobster-issue-card + + * missing reference to Verification Test + + .. raw:: html + +
+ + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `potato.trlc:24:13 `__ + +.. _lobster-item-fbfa01aa359da14836c3fe53cd7363bfb1ffb782: + +.. dropdown:: [MISSING] TRLC Linked\_requirement example.req\_important + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + this is important + + .. raw:: html + +
+ + **Traces to:** + + .. card:: + :class-card: lobster-issue-card + + * missing reference to Code + * missing reference to Verification Test + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`example.req\_nor ` + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:42:20 `__ + +.. _lobster-item-3e885dd2f9f061dedab93f44634897ace7f770c8: + +.. dropdown:: [OK] TRLC Tagged\_requirement example.req\_nand + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical negated and + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`nand ` + * :ref:`nand\_test::test\_1 ` + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:16:20 `__ + +.. _lobster-item-21cffb8e9b92e4133844f4752a7c8100cf3166c4: + +.. dropdown:: [JUSTIFIED] TRLC Requirement example.req\_implies + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + provide a utility function for logical implication + + .. raw:: html + +
+ + **Justifications:** not needed; also not needed + + .. raw:: html + +
+ + **Source:** `potato.trlc:30:13 `__ + +.. _lobster-item-1bb1a5e571e24eebc94d2572ab385ee34e338995: + +.. dropdown:: [OK] TRLC Tagged\_requirement example.req\_implication + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical implication + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`implication ` + * :ref:`ImplicationTest:BasicTest ` + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `tests\_integration/projects/basic/potato.trlc:3 `__ + +Implementation +============== + +.. _lobster-level-code: + +Code +---- + +**Coverage:** 60.0% (6 of 10 items OK) + +.. _lobster-item-f44a5e4c051c43e14dc1c71d4a3818a0b038597e: + +.. dropdown:: [MISSING] Simulink Block exclusive\_or + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx `__ + +.. _lobster-item-abac171105982dd5a053572a6f4d88ef30fbfc42: + +.. dropdown:: [MISSING] MATLAB Function exclusive\_or/MATLAB Function + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx:1:13 `__ + +.. _lobster-item-3412bc95eb06ccb9400eaa49f58bd0bfc7a08d60: + +.. dropdown:: [MISSING] C/C++ Function exclusive\_or + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `foo.cpp:9 `__ + +.. _lobster-item-4bf9a73c0893a50410b0817f54c7d89c696fa9a6: + +.. dropdown:: [MISSING] C/C++ Function potato + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `foo.cpp:14 `__ + +.. _lobster-item-d330ebddaa1ec4c761ec0c070c6f6c71fd4cbb4e: + +.. dropdown:: [OK] Simulink Block exclusive\_or/My Exclusive Or + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_xor ` + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx `__ + +.. _lobster-item-804973db4a4754175b5c3d8df2d938918dc8cae6: + +.. dropdown:: [OK] C/C++ Function implication + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `foo.cpp:3 `__ + +.. _lobster-item-05717df9b8ee784715376855d7afeabeaa6cd1f5: + +.. dropdown:: [OK] MATLAB Function nand + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `nand.m:1:14 `__ + +.. _lobster-item-716e7482019e79473ec2b20555b90c6183ef62a2: + +.. dropdown:: [JUSTIFIED] Python Function nor.trlc\_reference + :class-title: sd-bg-success sd-text-white + + **Justifications:** helper function + + .. raw:: html + +
+ + **Source:** `nor.py:5 `__ + +.. _lobster-item-44aca84976176f453e178bdd3323e7e9813dcebe: + +.. dropdown:: [OK] Python Method nor.Example.helper\_function + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nor ` + + .. raw:: html + +
+ + **Source:** `nor.py:13 `__ + +.. _lobster-item-4fe151cfc168f08ff6bc308291ecbee34309b652: + +.. dropdown:: [OK] Python Method nor.Example.nor + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nor ` + + .. raw:: html + +
+ + **Source:** `nor.py:17 `__ + +Verification and Validation +=========================== + +.. _lobster-level-verification-test: + +Verification Test +----------------- + +**Coverage:** 100.0% (2 of 2 items OK) + +.. _lobster-item-39288653f9b39978deae407a61549f53151857b7: + +.. dropdown:: [OK] MATLAB Test nand\_test::test\_1 + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `nand\_test.m:3:13 `__ + +.. _lobster-item-0c9dd74b464f7be6456f3d14e144ba8b116dc9b3: + +.. dropdown:: [OK] GoogleTest Test ImplicationTest:BasicTest + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `test.cpp:7 `__ + diff --git a/tests_system/lobster_rst_report/data/expected_single_page_no_dot.rst b/tests_system/lobster_rst_report/data/expected_single_page_no_dot.rst new file mode 100644 index 00000000..edeb1699 --- /dev/null +++ b/tests_system/lobster_rst_report/data/expected_single_page_no_dot.rst @@ -0,0 +1,517 @@ +################################## +L.O.B.S.T.E.R. Traceability Report +################################## + +| Analyzed commit: aaaa1111bbbb2222cccc3333dddd4444eeee5555 +| LOBSTER Version: 1.0.4-dev + +.. rubric:: Coverage Summary + +.. grid:: 1 1 2 2 + :gutter: 3 + + .. grid-item:: + :columns: 12 12 7 7 + + .. list-table:: + :header-rows: 1 + :widths: 35 15 15 15 + + * - Category + - Coverage + - OK Items + - Total Items + * - :ref:`System Requirements ` + - 100.0% + - 1 + - 1 + * - :ref:`Software Requirements ` + - 50.0% + - 3 + - 6 + * - :ref:`Code ` + - 60.0% + - 6 + - 10 + * - :ref:`Verification Test ` + - 100.0% + - 2 + - 2 + + .. grid-item:: + :columns: 12 12 5 5 + + .. note:: + + Tracing policy diagram omitted — Graphviz (dot) is not installed. + +.. rubric:: Issues + +* [MISSING — no upward trace] :ref:`exclusive\_or ` +* [MISSING — no upward trace] :ref:`exclusive\_or/MATLAB Function ` +* [MISSING — no upward trace] :ref:`exclusive\_or ` +* [MISSING — no upward trace] :ref:`potato ` +* [MISSING — version mismatch] :ref:`example.req\_xor ` +* [MISSING — no upward trace] :ref:`example.req\_nor ` +* [MISSING — no trace to: Verification Test] :ref:`example.req\_nor ` +* [MISSING — no trace to: Code] :ref:`example.req\_important ` +* [MISSING — no trace to: Verification Test] :ref:`example.req\_important ` + +Requirements and Specification +============================== + +.. _lobster-level-system-requirements: + +System Requirements +------------------- + +**Coverage:** 100.0% (1 of 1 items OK) + +.. _lobster-item-d499f1e2ff6c8662012d4c4bfe35060a5eda7b6c: + +.. dropdown:: [OK] codebeamer Functional requirement LOBSTER demo + :class-title: sd-bg-success sd-text-white + + **Status:** Potato + + .. pull-quote:: + + Provide a nice demonstration of LOBSTER through four examples + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`example.req\_implication ` + * :ref:`example.req\_xor ` + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `cb item 666 'LOBSTER demo' `__ + +.. _lobster-level-software-requirements: + +Software Requirements +--------------------- + +**Coverage:** 50.0% (3 of 6 items OK) + +.. _lobster-item-5c0948f96e743a0c3ab3c14ecac0f5d10609f324: + +.. dropdown:: [MISSING] TRLC Tagged\_requirement example.req\_xor + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical exclusive or + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`exclusive\_or/My Exclusive Or ` + + .. card:: + :class-card: lobster-issue-card + + * tracing destination req 12345 has version 42 (expected 5) + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:10:20 `__ + +.. _lobster-item-714c0c22dee57a69c9425cff0c0bd4ca9ebe5d1f: + +.. dropdown:: [MISSING] TRLC Requirement example.req\_nor + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + provide a utility function for logical negated or + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`nor.Example.helper\_function ` + * :ref:`nor.Example.nor ` + + .. card:: + :class-card: lobster-issue-card + + * missing reference to Verification Test + + .. raw:: html + +
+ + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `potato.trlc:24:13 `__ + +.. _lobster-item-fbfa01aa359da14836c3fe53cd7363bfb1ffb782: + +.. dropdown:: [MISSING] TRLC Linked\_requirement example.req\_important + :open: + :class-title: sd-bg-danger sd-text-white + + .. pull-quote:: + + this is important + + .. raw:: html + +
+ + **Traces to:** + + .. card:: + :class-card: lobster-issue-card + + * missing reference to Code + * missing reference to Verification Test + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`example.req\_nor ` + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:42:20 `__ + +.. _lobster-item-3e885dd2f9f061dedab93f44634897ace7f770c8: + +.. dropdown:: [OK] TRLC Tagged\_requirement example.req\_nand + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical negated and + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`nand ` + * :ref:`nand\_test::test\_1 ` + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `potato.trlc:16:20 `__ + +.. _lobster-item-21cffb8e9b92e4133844f4752a7c8100cf3166c4: + +.. dropdown:: [JUSTIFIED] TRLC Requirement example.req\_implies + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + provide a utility function for logical implication + + .. raw:: html + +
+ + **Justifications:** not needed; also not needed + + .. raw:: html + +
+ + **Source:** `potato.trlc:30:13 `__ + +.. _lobster-item-1bb1a5e571e24eebc94d2572ab385ee34e338995: + +.. dropdown:: [OK] TRLC Tagged\_requirement example.req\_implication + :class-title: sd-bg-success sd-text-white + + .. pull-quote:: + + text: provide a utility function for logical implication + + .. raw:: html + +
+ + **Traces to:** + + * :ref:`implication ` + * :ref:`ImplicationTest:BasicTest ` + + .. raw:: html + +
+ + **Derived from:** + + * :ref:`LOBSTER demo ` + + .. raw:: html + +
+ + **Source:** `tests\_integration/projects/basic/potato.trlc:3 `__ + +Implementation +============== + +.. _lobster-level-code: + +Code +---- + +**Coverage:** 60.0% (6 of 10 items OK) + +.. _lobster-item-f44a5e4c051c43e14dc1c71d4a3818a0b038597e: + +.. dropdown:: [MISSING] Simulink Block exclusive\_or + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx `__ + +.. _lobster-item-abac171105982dd5a053572a6f4d88ef30fbfc42: + +.. dropdown:: [MISSING] MATLAB Function exclusive\_or/MATLAB Function + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx:1:13 `__ + +.. _lobster-item-3412bc95eb06ccb9400eaa49f58bd0bfc7a08d60: + +.. dropdown:: [MISSING] C/C++ Function exclusive\_or + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `foo.cpp:9 `__ + +.. _lobster-item-4bf9a73c0893a50410b0817f54c7d89c696fa9a6: + +.. dropdown:: [MISSING] C/C++ Function potato + :open: + :class-title: sd-bg-danger sd-text-white + + **Derived from:** + + .. card:: + :class-card: lobster-issue-card + + * missing up reference + + .. raw:: html + +
+ + **Source:** `foo.cpp:14 `__ + +.. _lobster-item-d330ebddaa1ec4c761ec0c070c6f6c71fd4cbb4e: + +.. dropdown:: [OK] Simulink Block exclusive\_or/My Exclusive Or + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_xor ` + + .. raw:: html + +
+ + **Source:** `exclusive\_or.slx `__ + +.. _lobster-item-804973db4a4754175b5c3d8df2d938918dc8cae6: + +.. dropdown:: [OK] C/C++ Function implication + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `foo.cpp:3 `__ + +.. _lobster-item-05717df9b8ee784715376855d7afeabeaa6cd1f5: + +.. dropdown:: [OK] MATLAB Function nand + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `nand.m:1:14 `__ + +.. _lobster-item-716e7482019e79473ec2b20555b90c6183ef62a2: + +.. dropdown:: [JUSTIFIED] Python Function nor.trlc\_reference + :class-title: sd-bg-success sd-text-white + + **Justifications:** helper function + + .. raw:: html + +
+ + **Source:** `nor.py:5 `__ + +.. _lobster-item-44aca84976176f453e178bdd3323e7e9813dcebe: + +.. dropdown:: [OK] Python Method nor.Example.helper\_function + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nor ` + + .. raw:: html + +
+ + **Source:** `nor.py:13 `__ + +.. _lobster-item-4fe151cfc168f08ff6bc308291ecbee34309b652: + +.. dropdown:: [OK] Python Method nor.Example.nor + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nor ` + + .. raw:: html + +
+ + **Source:** `nor.py:17 `__ + +Verification and Validation +=========================== + +.. _lobster-level-verification-test: + +Verification Test +----------------- + +**Coverage:** 100.0% (2 of 2 items OK) + +.. _lobster-item-39288653f9b39978deae407a61549f53151857b7: + +.. dropdown:: [OK] MATLAB Test nand\_test::test\_1 + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_nand ` + + .. raw:: html + +
+ + **Source:** `nand\_test.m:3:13 `__ + +.. _lobster-item-0c9dd74b464f7be6456f3d14e144ba8b116dc9b3: + +.. dropdown:: [OK] GoogleTest Test ImplicationTest:BasicTest + :class-title: sd-bg-success sd-text-white + + **Derived from:** + + * :ref:`example.req\_implication ` + + .. raw:: html + +
+ + **Source:** `test.cpp:7 `__ + diff --git a/tests_system/lobster_rst_report/test_rst_report_input_file.py b/tests_system/lobster_rst_report/test_rst_report_input_file.py index 06c8b2fe..705b5177 100644 --- a/tests_system/lobster_rst_report/test_rst_report_input_file.py +++ b/tests_system/lobster_rst_report/test_rst_report_input_file.py @@ -1,9 +1,21 @@ +import shutil +import subprocess import unittest +import unittest.mock +from lobster.common.graphviz_utils import is_dot_available +from lobster.common.location import File_Reference +from lobster.tools.core.rst_report._helpers import ItemNaming +from lobster.tools.core.rst_report.rst_report import _get_git_commit from tests_system.asserter import Asserter from tests_system.lobster_rst_report.lobster_rst_report_system_test_case_base import ( LobsterRstReportSystemTestCaseBase, ) +from tests_system.tests_utils.update_version_in_rst_file import ( + update_version_in_rst_file, +) + +_FIXED_COMMIT = "aaaa1111bbbb2222cccc3333dddd4444eeee5555" class RstReportInputFileTest(LobsterRstReportSystemTestCaseBase): @@ -130,10 +142,16 @@ def _get_single_page_content(self) -> str: def test_tracing_policy_diagram_in_output(self): # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram_Fallback content = self._get_single_page_content() - # The policy diagram is always emitted as a Graphviz RST directive. - self.assertIn(".. graphviz::", content) - self.assertIn("digraph tracing_policy", content) + if is_dot_available(dot=None): + # dot is installed — the diagram is rendered as a Graphviz directive + self.assertIn(".. graphviz::", content) + self.assertIn("digraph tracing_policy", content) + else: + # dot is not installed — a fallback note is emitted instead + self.assertIn(".. note::", content) + self.assertIn("Tracing policy diagram omitted", content) def test_covered_requirements_listed(self): # lobster-trace: UseCases.RST_Covered_Requirement_list @@ -195,6 +213,8 @@ def test_source_locations_in_output(self): self.assertIn("potato.kitten/issue/666", content) # GitHub URL for software requirements self.assertIn("github.com/bmw-software-engineering/lobster", content) + # Links must be RST anonymous hyperlinks (not plain text) + self.assertIn("`__", content) # ------------------------------------------------------------------ # Multi-page content verification @@ -263,5 +283,127 @@ def test_codebeamer_item_name_displayed(self): self.assertIn("LOBSTER demo", content) +class RstReportGoldenOutputTest(LobsterRstReportSystemTestCaseBase): + """Golden-file comparison tests for lobster-rst-report single-page output. + + Pattern mirrors tests_system/lobster_html_report/test_html_content.py: + * Real ``is_dot_available(dot=None)`` call — no mocking of dot. + * Two golden files, one per dot-availability state. + * ``_get_git_commit`` is patched to a fixed hash so output is deterministic. + * ``update_version_in_rst_file`` updates the LOBSTER version in the golden + file copy before ``assertOutputFiles()`` compares it to the actual output. + """ + + def setUp(self): + super().setUp() + self._test_runner = self.create_test_runner() + + def test_golden_single_page_output(self): + # lobster-trace: UseCases.RST_File_generation + # lobster-trace: rst_req.RST_Report_Single_Page + # lobster-trace: rst_req.RST_Report_Header + # lobster-trace: rst_req.RST_Report_Coverage_Table + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram + # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram_Fallback + """Full single-page RST output matches the checked-in golden file.""" + if is_dot_available(dot=None): + golden_name = "expected_single_page.rst" + expected_stdout_suffix = "LOBSTER RST report written to report.rst\n" + else: + golden_name = "expected_single_page_no_dot.rst" + expected_stdout_suffix = ( + "warning: dot utility not found, report will not include " + "the tracing policy visualisation\n" + "> please install Graphviz (https://graphviz.org)\n" + "LOBSTER RST report written to report.rst\n" + ) + + golden_src = self._data_directory / golden_name + golden_copy = self._test_runner.working_dir / golden_name + + # Copy + update version so the golden file reflects the installed release + shutil.copy2(golden_src, golden_copy) + update_version_in_rst_file(golden_copy) + + self._test_runner.declare_input_file( + self._data_directory / "basic_report.lobster" + ) + self._test_runner.declare_output_file(golden_copy) + self._test_runner.cmd_args.lobster_report = "basic_report.lobster" + self._test_runner.cmd_args.out = "report.rst" + + patch_target = ( + "lobster.tools.core.rst_report.rst_report._get_git_commit" + ) + with unittest.mock.patch(patch_target, return_value=_FIXED_COMMIT): + completed_process = self._test_runner.run_tool_test() + + asserter = Asserter(self, completed_process, self._test_runner) + asserter.assertExitCode(0) + asserter.assertStdOutText(expected_stdout_suffix) + asserter.assertOutputFiles() + + +class GitCommitHelperTest(unittest.TestCase): + """Unit tests for the ``_get_git_commit()`` report-header helper.""" + + _RUN_TARGET = "lobster.tools.core.rst_report.rst_report.subprocess.run" + + def test_returns_stripped_commit_hash(self): + # lobster-trace: rst_req.RST_Report_Header + completed = subprocess.CompletedProcess( + args=["git", "rev-parse", "HEAD"], + returncode=0, + stdout=f"{_FIXED_COMMIT}\n", + stderr="", + ) + with unittest.mock.patch(self._RUN_TARGET, return_value=completed): + self.assertEqual(_get_git_commit(), _FIXED_COMMIT) + + def test_returns_unknown_when_git_not_installed(self): + # lobster-trace: rst_req.RST_Report_Header + with unittest.mock.patch(self._RUN_TARGET, side_effect=FileNotFoundError): + self.assertEqual(_get_git_commit(), "(unknown)") + + def test_returns_unknown_outside_git_repository(self): + # lobster-trace: rst_req.RST_Report_Header + error = subprocess.CalledProcessError(128, ["git", "rev-parse", "HEAD"]) + with unittest.mock.patch(self._RUN_TARGET, side_effect=error): + self.assertEqual(_get_git_commit(), "(unknown)") + + def test_returns_unknown_on_timeout(self): + # lobster-trace: rst_req.RST_Report_Header + error = subprocess.TimeoutExpired(["git", "rev-parse", "HEAD"], 5) + with unittest.mock.patch(self._RUN_TARGET, side_effect=error): + self.assertEqual(_get_git_commit(), "(unknown)") + + +class SourceRootNormalizationTest(unittest.TestCase): + """Unit tests for ``--source-root`` trailing-slash normalization in links.""" + + def test_source_root_without_trailing_slash_gets_one(self): + # lobster-trace: rst_req.RST_Source_Root_Prefix + link = ItemNaming.location_link( + File_Reference("src/module/foo.cpp"), source_root="../.." + ) + self.assertIn("<../../src/module/foo.cpp>", link) + self.assertNotIn("..//", link) + + def test_source_root_with_trailing_slash_not_duplicated(self): + # lobster-trace: rst_req.RST_Source_Root_Prefix + link = ItemNaming.location_link( + File_Reference("src/module/foo.cpp"), source_root="../../" + ) + self.assertIn("<../../src/module/foo.cpp>", link) + self.assertNotIn("..//", link) + + def test_no_source_root_uses_plain_filename(self): + # lobster-trace: rst_req.RST_Source_Root_Prefix + link = ItemNaming.location_link( + File_Reference("src/module/foo.cpp"), source_root="" + ) + self.assertIn("", link) + + if __name__ == "__main__": unittest.main() diff --git a/tests_system/tests_utils/BUILD.bazel b/tests_system/tests_utils/BUILD.bazel index 81ba61f8..2b54a306 100644 --- a/tests_system/tests_utils/BUILD.bazel +++ b/tests_system/tests_utils/BUILD.bazel @@ -32,6 +32,7 @@ py_library( "update_html_expected_output.py", "update_online_json_with_hashes.py", "update_version_in_html.py", + "update_version_in_rst_file.py", ], visibility = ["//visibility:public"], ) diff --git a/tests_system/tests_utils/update_version_in_rst_file.py b/tests_system/tests_utils/update_version_in_rst_file.py new file mode 100644 index 00000000..fe5e3bbe --- /dev/null +++ b/tests_system/tests_utils/update_version_in_rst_file.py @@ -0,0 +1,50 @@ +""" +Script to update LOBSTER version in a generated RST report file. + +Mirrors the pattern used by update_version_in_html.py for HTML reports. +""" + +import re + +from lobster.common.version import LOBSTER_VERSION + + +def update_version_in_rst_file(file_path) -> bool: + """Update the LOBSTER version line in a golden RST file. + + The generated RST header contains a line of the form:: + + | LOBSTER Version: X.Y.Z + + This function replaces the version token with the currently installed + LOBSTER version so that the golden file matches actual tool output + regardless of which release is under test. + + Args: + file_path: Path to the RST file to update (str or :class:`pathlib.Path`). + + Returns: + ``True`` if the version line was found and updated, ``False`` otherwise. + """ + with open(file_path, "r", encoding="utf-8") as fh: + content = fh.read() + + version_pattern = r"\| LOBSTER Version: [^\n]+" + + if not re.search(version_pattern, content): + print(f"LOBSTER version line not found in {file_path}") + return False + + updated_content = re.sub( + r"(\| LOBSTER Version: )[^\n]+", + rf"\g<1>{LOBSTER_VERSION}", + content, + ) + + with open(file_path, "w", encoding="utf-8") as fh: + fh.write(updated_content) + + print( + f"Updated LOBSTER version to {LOBSTER_VERSION} in {file_path}" + ) + return True From 925216b8959f04ece56009f1ddf17f6ea90bcbfc Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Mon, 29 Jun 2026 16:54:42 +0200 Subject: [PATCH 3/3] fix file compare --- .../test_rst_report_input_file.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests_system/lobster_rst_report/test_rst_report_input_file.py b/tests_system/lobster_rst_report/test_rst_report_input_file.py index 705b5177..ce037acd 100644 --- a/tests_system/lobster_rst_report/test_rst_report_input_file.py +++ b/tests_system/lobster_rst_report/test_rst_report_input_file.py @@ -1,4 +1,3 @@ -import shutil import subprocess import unittest import unittest.mock @@ -308,21 +307,28 @@ def test_golden_single_page_output(self): """Full single-page RST output matches the checked-in golden file.""" if is_dot_available(dot=None): golden_name = "expected_single_page.rst" - expected_stdout_suffix = "LOBSTER RST report written to report.rst\n" else: golden_name = "expected_single_page_no_dot.rst" - expected_stdout_suffix = ( + + expected_stdout_suffix = ( + ( "warning: dot utility not found, report will not include " "the tracing policy visualisation\n" "> please install Graphviz (https://graphviz.org)\n" - "LOBSTER RST report written to report.rst\n" ) + if not is_dot_available(dot=None) + else "" + ) + f"LOBSTER RST report written to {golden_name}\n" golden_src = self._data_directory / golden_name - golden_copy = self._test_runner.working_dir / golden_name - # Copy + update version so the golden file reflects the installed release - shutil.copy2(golden_src, golden_copy) + # Copy the golden file into a separate temp dir so assertOutputFiles() + # can compare it against the tool's actual output in working_dir. + # (Both must share the same basename; they must be in different dirs.) + output_dir = self.create_output_directory_and_copy_expected( + self._data_directory.parent, golden_src + ) + golden_copy = output_dir / golden_name update_version_in_rst_file(golden_copy) self._test_runner.declare_input_file( @@ -330,7 +336,7 @@ def test_golden_single_page_output(self): ) self._test_runner.declare_output_file(golden_copy) self._test_runner.cmd_args.lobster_report = "basic_report.lobster" - self._test_runner.cmd_args.out = "report.rst" + self._test_runner.cmd_args.out = golden_name patch_target = ( "lobster.tools.core.rst_report.rst_report._get_git_commit"