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

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. 

21 

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. 

24 

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 

31 

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""" 

37 

38from typing import Dict 

39 

40from lobster.common.report import Report 

41from lobster.common.items import Tracing_Status, Item, Requirement 

42 

43from ._helpers import RstUtils, ItemNaming, TracingClassifier, PolicyDiagramBuilder 

44 

45 

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] 

52 

53 

54def _build_page_map(report: Report) -> Dict[str, str]: 

55 """Build a mapping from level name to filesystem-safe page stem. 

56 

57 Duplicate stems (produced when two level names collapse to the same slug) 

58 are disambiguated by appending a numeric suffix. 

59 

60 Args: 

61 report: The loaded LOBSTER report. 

62 

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 

78 

79 

80class ItemCardBuilder: 

81 """Build a sphinx-design card for a single LOBSTER tracing item. 

82 

83 The card is structured in three sections: 

84 

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. 

90 

91 Usage:: 

92 

93 lines = ItemCardBuilder(item, report, source_root).build() 

94 """ 

95 

96 def __init__(self, item: Item, report: Report, source_root: str = ""): 

97 """Initialise the builder. 

98 

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 

108 

109 def build(self) -> list: 

110 """Return RST lines for the item dropdown. 

111 

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. 

115 

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)}" 

127 

128 out = [] 

129 

130 # Anchor label 

131 out.append(f".. _{ItemNaming.item_label(item)}:") 

132 out.append("") 

133 

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("") 

142 

143 def body(text=""): 

144 """Append a line at 3-space indent (card body level).""" 

145 out.append(f" {text}".rstrip()) 

146 

147 def nested(text=""): 

148 """Append a line at 6-space indent (nested directive body).""" 

149 out.append(f" {text}".rstrip()) 

150 

151 def sep(): 

152 """Emit an HTML horizontal rule separator.""" 

153 body(".. raw:: html") 

154 out.append("") 

155 nested("<hr>") 

156 out.append("") 

157 

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("") 

166 

167 # Track whether any body content has been emitted (drives separator logic). 

168 has_content = False 

169 

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 

176 

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 

185 

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 

202 

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 

219 

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 

229 

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) 

235 

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("") 

244 

245 return out 

246 

247 def _resolve_refs(self, refs) -> list: 

248 """Resolve tracing references to RST cross-reference strings. 

249 

250 Known items are rendered as ``:ref:`` links; unresolvable references 

251 fall back to a code literal of the reference key. 

252 

253 Args: 

254 refs: An iterable of reference objects with a ``key()`` method. 

255 

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 

271 

272 

273class LevelSectionBuilder: 

274 """Build the RST body for a single tracing level. 

275 

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. 

279 

280 Usage:: 

281 

282 lines = LevelSectionBuilder(items, report, source_root).build() 

283 """ 

284 

285 def __init__(self, items: list, report: Report, source_root: str = ""): 

286 """Initialise the builder. 

287 

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 

296 

297 def build(self) -> list: 

298 """Return RST lines for the level body. 

299 

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. 

303 

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 ) 

324 

325 out = [] 

326 for it in issue_items + ok_items: 

327 out += ItemCardBuilder(it, self._report, self._source_root).build() 

328 

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.", ""] 

331 

332 return out 

333 

334 

335class CoverageGridBuilder: 

336 """Build the coverage summary section (table + policy diagram side by side). 

337 

338 Uses a sphinx-design ``.. grid:: 1 1 2 2`` layout: 

339 

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. 

344 

345 Usage:: 

346 

347 ref_fn = lambda n: f":doc:`{n} <page_stem>`" 

348 lines = CoverageGridBuilder(report).build(ref_fn) 

349 """ 

350 

351 def __init__(self, report: Report): 

352 """Initialise the builder. 

353 

354 Args: 

355 report: The loaded LOBSTER report, providing coverage data and config. 

356 """ 

357 self._report = report 

358 

359 def build(self, ref_fn, dot_available: bool = True) -> list: 

360 """Return RST lines for the coverage grid. 

361 

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`. 

368 

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 

401 

402 

403class IssuesListBuilder: 

404 """Build the index-page issues summary list. 

405 

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. 

409 

410 Usage:: 

411 

412 lines = IssuesListBuilder(report).build() 

413 """ 

414 

415 def __init__(self, report: Report): 

416 """Initialise the builder. 

417 

418 Args: 

419 report: The loaded LOBSTER report. 

420 """ 

421 self._report = report 

422 

423 def build(self) -> list: 

424 """Return RST lines for the issues list. 

425 

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