Coverage for lobster/tools/core/rst_report/_renderers.py: 92%
169 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-15 06:41 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-15 06:41 +0000
1#!/usr/bin/env python3
2#
3# lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx
4# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
5#
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU Affero General Public License as
8# published by the Free Software Foundation, either version 3 of the
9# License, or (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful, but
12# WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14# Affero General Public License for more details.
15#
16# You should have received a copy of the GNU Affero General Public
17# License along with this program. If not, see
18# <https://www.gnu.org/licenses/>.
19"""
20RST block builder classes for the RST report tool.
22Each class accepts LOBSTER report data and returns RST lines via a ``build()``
23method. Lines can be joined with ``"\\n".join(lines)`` to form valid RST.
25Classes
26-------
27* :class:`ItemCardBuilder` -- one item as a sphinx-design card
28* :class:`LevelSectionBuilder` -- all items for one level (Issues + OK Items)
29* :class:`CoverageGridBuilder` -- coverage table + graphviz policy diagram
30* :class:`IssuesListBuilder` -- index-page issue summary list
32Module-level helpers
33--------------------
34* :data:`_KIND_ORDER` -- canonical display order for level kinds
35* :func:`_build_page_map` -- ``{level_name: page_stem}`` mapping
36"""
38from typing import Dict
40from lobster.common.report import Report
41from lobster.common.items import Tracing_Status, Item, Requirement
43from ._helpers import RstUtils, ItemNaming, TracingClassifier, PolicyDiagramBuilder
46#: Canonical display order: list of ``(kind_value, section_title)`` pairs.
47_KIND_ORDER = [
48 ("requirements", "Requirements and Specification"),
49 ("implementation", "Implementation"),
50 ("activity", "Verification and Validation"),
51]
54def _build_page_map(report: Report) -> Dict[str, str]:
55 """Build a mapping from level name to filesystem-safe page stem.
57 Duplicate stems (produced when two level names collapse to the same slug)
58 are disambiguated by appending a numeric suffix.
60 Args:
61 report: The loaded LOBSTER report.
63 Returns:
64 A ``dict`` of ``{level_name: page_stem}`` where every value is a
65 unique, filesystem-safe string without a file extension.
66 """
67 seen: Dict[str, int] = {}
68 page_map: Dict[str, str] = {}
69 for level_name in report.config:
70 stem = ItemNaming.level_page_name(level_name)
71 if stem in seen: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 seen[stem] += 1
73 stem = f"{stem}_{seen[stem]}"
74 else:
75 seen[stem] = 0
76 page_map[level_name] = stem
77 return page_map
80class ItemCardBuilder:
81 """Build a sphinx-design card for a single LOBSTER tracing item.
83 The card is structured in three sections:
85 * **Header** (coloured stripe) -- status badge and item name, styled via
86 the ``^^^`` separator so that ``:class-header:`` CSS is applied.
87 * **Body** -- workflow status, description, tracing links, and any inline
88 issue messages (no ``.. warning::`` admonition box).
89 * **Footer** -- source location link.
91 Usage::
93 lines = ItemCardBuilder(item, report, source_root).build()
94 """
96 def __init__(self, item: Item, report: Report, source_root: str = ""):
97 """Initialise the builder.
99 Args:
100 item: The LOBSTER item to render.
101 report: The full report, needed to resolve cross-references.
102 source_root: Optional URL prefix prepended to plain file-reference
103 paths (see :meth:`ItemNaming.location_link`).
104 """
105 self._item = item
106 self._report = report
107 self._source_root = source_root
109 def build(self) -> list:
110 """Return RST lines for the item dropdown.
112 All items use ``.. dropdown::``. OK and JUSTIFIED items start closed
113 (low visual weight); all other statuses start open. Issue messages
114 are rendered as nested open red dropdowns -- no custom CSS required.
116 Returns:
117 A list of RST lines ending with a blank string.
118 """
119 item = self._item
120 e = RstUtils.escape
121 status = item.tracing_status.name if item.tracing_status else "UNKNOWN"
122 kind_str = ItemNaming.item_kind_str(item)
123 down_msgs, up_msgs, gen_msgs = TracingClassifier.categorize(item.messages)
124 is_ok = status in ("OK", "JUSTIFIED")
125 hdr_class = TracingClassifier.card_header_class(status)
126 title = f"[{status}] {e(kind_str)} {e(item.name)}"
128 out = []
130 # Anchor label
131 out.append(f".. _{ItemNaming.item_label(item)}:")
132 out.append("")
134 # lobster-trace: rst_req.RST_Report_Item_Details
135 # lobster-trace: UseCases.Colored_Findings
136 # All items are dropdowns; non-OK items start open.
137 out.append(f".. dropdown:: {title}")
138 if not is_ok:
139 out.append(" :open:")
140 out.append(f" :class-title: {hdr_class}")
141 out.append("")
143 def body(text=""):
144 """Append a line at 3-space indent (card body level)."""
145 out.append(f" {text}".rstrip())
147 def nested(text=""):
148 """Append a line at 6-space indent (nested directive body)."""
149 out.append(f" {text}".rstrip())
151 def sep():
152 """Emit an HTML horizontal rule separator."""
153 body(".. raw:: html")
154 out.append("")
155 nested("<hr>")
156 out.append("")
158 def issue_box(msgs):
159 """Render issue messages as a light-red card body (no heading)."""
160 body(".. card::")
161 nested(":class-card: lobster-issue-card")
162 out.append("")
163 for msg in msgs:
164 nested(f"* {e(msg)}")
165 out.append("")
167 # Track whether any body content has been emitted (drives separator logic).
168 has_content = False
170 # lobster-trace: rst_req.RST_Report_Item_Details
171 # Workflow status (requirements only)
172 if isinstance(item, Requirement) and item.status:
173 body(f"**Status:** {e(item.status)}")
174 out.append("")
175 has_content = True
177 # Description text (requirements only)
178 if isinstance(item, Requirement) and item.text:
179 body(".. pull-quote::")
180 out.append("")
181 for text_line in item.text.splitlines():
182 nested(e(text_line))
183 out.append("")
184 has_content = True
186 # lobster-trace: UseCases.List_Requirements_to_Tests
187 # lobster-trace: UseCases.List_Tests_to_Requirements
188 # Downward traces section
189 has_down = bool(item.ref_down or down_msgs)
190 if has_down:
191 if has_content: 191 ↛ 193line 191 didn't jump to line 193 because the condition on line 191 was always true
192 sep()
193 body("**Traces to:**")
194 out.append("")
195 for ref_str in self._resolve_refs(item.ref_down):
196 body(f"* {ref_str}")
197 if item.ref_down:
198 out.append("")
199 if down_msgs:
200 issue_box(down_msgs)
201 has_content = True
203 # lobster-trace: UseCases.List_Requirements_without_Tests
204 # lobster-trace: UseCases.List_Tests_without_Requirements
205 # Upward traces section
206 has_up = bool(item.ref_up or up_msgs)
207 if has_up:
208 if has_content:
209 sep()
210 body("**Derived from:**")
211 out.append("")
212 for ref_str in self._resolve_refs(item.ref_up):
213 body(f"* {ref_str}")
214 if item.ref_up:
215 out.append("")
216 if up_msgs:
217 issue_box(up_msgs)
218 has_content = True
220 # Justification text
221 if item.tracing_status == Tracing_Status.JUSTIFIED:
222 justifications = item.just_global + item.just_up + item.just_down
223 if justifications: 223 ↛ 231line 223 didn't jump to line 231 because the condition on line 223 was always true
224 if has_content:
225 sep()
226 body(f"**Justifications:** {'; '.join(e(j) for j in justifications)}")
227 out.append("")
228 has_content = True
230 # General messages (not clearly upward or downward)
231 if gen_msgs: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 if has_content:
233 sep()
234 issue_box(gen_msgs)
236 # lobster-trace: UseCases.Item_GitHub_Source
237 # lobster-trace: UseCases.Show_codebeamer_links
238 # Source location: always inline at end (.. dropdown:: has no +++ footer).
239 source_link = ItemNaming.location_link(item.location, self._source_root)
240 if has_content or gen_msgs: 240 ↛ 242line 240 didn't jump to line 242 because the condition on line 240 was always true
241 sep()
242 body(f"**Source:** {source_link}")
243 out.append("")
245 return out
247 def _resolve_refs(self, refs) -> list:
248 """Resolve tracing references to RST cross-reference strings.
250 Known items are rendered as ``:ref:`` links; unresolvable references
251 fall back to a code literal of the reference key.
253 Args:
254 refs: An iterable of reference objects with a ``key()`` method.
256 Returns:
257 A list of RST inline strings (one per reference).
258 """
259 parts = []
260 for ref in refs:
261 key = ref.key()
262 if key in self._report.items: 262 ↛ 269line 262 didn't jump to line 269 because the condition on line 262 was always true
263 ref_item = self._report.items[key]
264 parts.append(
265 f":ref:`{RstUtils.escape(ref_item.name)}"
266 f" <{ItemNaming.item_label(ref_item)}>`"
267 )
268 else:
269 parts.append(f"``{RstUtils.escape(key)}`` (unresolved)")
270 return parts
273class LevelSectionBuilder:
274 """Build the RST body for a single tracing level.
276 Issue items (open red dropdowns) are emitted before OK items (closed green
277 dropdowns). The dropdown open/closed state communicates status visually,
278 so no filter TOC or sub-section headings are added.
280 Usage::
282 lines = LevelSectionBuilder(items, report, source_root).build()
283 """
285 def __init__(self, items: list, report: Report, source_root: str = ""):
286 """Initialise the builder.
288 Args:
289 items: The list of LOBSTER items belonging to this level.
290 report: The full report, needed for cross-reference resolution.
291 source_root: Optional URL prefix prepended to file-reference paths.
292 """
293 self._items = items
294 self._report = report
295 self._source_root = source_root
297 def build(self) -> list:
298 """Return RST lines for the level body.
300 Issue items (non-OK/non-JUSTIFIED) come first, sorted by location,
301 followed by OK/JUSTIFIED items. No filter TOC or sub-headings are
302 emitted; the dropdown open/closed state communicates status visually.
304 Returns:
305 A list of RST lines.
306 """
307 issue_items = sorted(
308 (
309 it
310 for it in self._items
311 if it.tracing_status
312 not in (Tracing_Status.OK, Tracing_Status.JUSTIFIED)
313 ),
314 key=lambda x: x.location.sorting_key(),
315 )
316 ok_items = sorted(
317 (
318 it
319 for it in self._items
320 if it.tracing_status in (Tracing_Status.OK, Tracing_Status.JUSTIFIED)
321 ),
322 key=lambda x: x.location.sorting_key(),
323 )
325 out = []
326 for it in issue_items + ok_items:
327 out += ItemCardBuilder(it, self._report, self._source_root).build()
329 if not out: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 out += ["No items recorded at this level.", ""]
332 return out
335class CoverageGridBuilder:
336 """Build the coverage summary section (table + policy diagram side by side).
338 Uses a sphinx-design ``.. grid:: 1 1 2 2`` layout:
340 * Left column (7/12 width on desktop) -- a ``.. list-table::`` with
341 per-level coverage statistics and links to each level.
342 * Right column (5/12 width on desktop) -- the :class:`PolicyDiagramBuilder`
343 graphviz diagram showing level kinds and tracing relationships.
345 Usage::
347 ref_fn = lambda n: f":doc:`{n} <page_stem>`"
348 lines = CoverageGridBuilder(report).build(ref_fn)
349 """
351 def __init__(self, report: Report):
352 """Initialise the builder.
354 Args:
355 report: The loaded LOBSTER report, providing coverage data and config.
356 """
357 self._report = report
359 def build(self, ref_fn, dot_available: bool = True) -> list:
360 """Return RST lines for the coverage grid.
362 Args:
363 ref_fn: A callable ``(level_name: str) -> str`` returning an RST
364 cross-reference for the given level. Use ``:ref:`` links for
365 single-page output and ``:doc:`` links for multi-page output.
366 dot_available: Whether the Graphviz ``dot`` executable is
367 available. Forwarded to :class:`PolicyDiagramBuilder`.
369 Returns:
370 A list of RST lines ending with a blank string.
371 """
372 # lobster-trace: UseCases.Item_Coverage
373 # lobster-trace: rst_req.RST_Report_Coverage_Table
374 lines = []
375 lines += [".. grid:: 1 1 2 2", " :gutter: 3", ""]
376 lines += [" .. grid-item::", " :columns: 12 12 7 7", ""]
377 lines += [
378 " .. list-table::",
379 " :header-rows: 1",
380 " :widths: 35 15 15 15",
381 "",
382 ]
383 lines += [
384 " * - Category",
385 " - Coverage",
386 " - OK Items",
387 " - Total Items",
388 ]
389 for level_name in self._report.config:
390 data = self._report.coverage[level_name]
391 lines.append(f" * - {ref_fn(level_name)}")
392 lines.append(f" - {data.coverage:.1f}%")
393 lines.append(f" - {data.ok}")
394 lines.append(f" - {data.items}")
395 lines.append("")
396 lines += [" .. grid-item::", " :columns: 12 12 5 5", ""]
397 lines += PolicyDiagramBuilder.build(
398 self._report, indent=6, dot_available=dot_available
399 )
400 return lines
403class IssuesListBuilder:
404 """Build the index-page issues summary list.
406 Each issue is rendered as a bullet with a granular tag derived from the
407 message text (e.g. ``[MISSING — version mismatch]``) and a cross-page
408 ``:ref:`` link to the corresponding item card.
410 Usage::
412 lines = IssuesListBuilder(report).build()
413 """
415 def __init__(self, report: Report):
416 """Initialise the builder.
418 Args:
419 report: The loaded LOBSTER report.
420 """
421 self._report = report
423 def build(self) -> list:
424 """Return RST lines for the issues list.
426 Returns:
427 A list of RST bullet items, one per issue message. If no issues
428 exist, a single ``"No traceability issues found."`` line is returned.
429 """
430 # lobster-trace: rst_req.RST_Report_Issues_List
431 # lobster-trace: UseCases.List_Findings
432 lines = []
433 found_any = False
434 for item in sorted(
435 self._report.items.values(), key=lambda x: x.location.sorting_key()
436 ):
437 if item.tracing_status in (Tracing_Status.OK, Tracing_Status.JUSTIFIED):
438 continue
439 for message in item.messages:
440 tag = TracingClassifier.issue_tag(message)
441 item_ref = (
442 f":ref:`{RstUtils.escape(item.name)}"
443 f" <{ItemNaming.item_label(item)}>`"
444 )
445 lines.append(f"* [{item.tracing_status.name} — {tag}] {item_ref}")
446 found_any = True
447 if not found_any: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true
448 lines.append("No traceability issues found.")
449 return lines