diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index 6a6368fb..e0e77aea 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -1026,9 +1026,12 @@ def _dependable_element_index_impl(ctx): lobster_rst_dir = ctx.actions.declare_directory( ctx.label.name + "/traceability_report", ) - package = ctx.label.package - package_depth = len(package.split("/")) if package else 0 - source_root = "/".join([".." for _ in range(package_depth + 2)]) + "/" + + source_root = ctx.var.get("LOBSTER_SOURCE_ROOT", "") + if not source_root: + package = ctx.label.package + package_depth = len(package.split("/")) if package else 0 + source_root = "/".join([".." for _ in range(package_depth + 2)]) + "/" rst_args = ctx.actions.args() rst_args.add(lobster_report_file.path) rst_args.add_all(["--out-dir", lobster_rst_dir.path]) diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 219e1611..5e0eb59e 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -256,6 +256,18 @@ def _score_html_impl(ctx): merge_inputs.append(dep_html_dir) merge_args.extend(["--dep", dep_name + ":" + dep_html_dir.path]) + # Auto-detect static files from srcs: any file whose short_path contains + # '/_static/' is a static asset that Sphinx may not copy correctly in the + # Bazel sandbox (confdir != srcdir prevents html_static_path from resolving). + # Copy them explicitly into output/_static/ via the merge step. + for orig_file in ctx.files.srcs: + path = orig_file.short_path + static_marker = "/_static/" + if static_marker in path: + subpath = path[path.index(static_marker) + len(static_marker):] + merge_args.extend(["--extra-static", orig_file.path + ":" + subpath]) + merge_inputs.append(orig_file) + # Merging html files ctx.actions.run( inputs = merge_inputs, diff --git a/bazel/rules/rules_score/src/sphinx_html_merge.py b/bazel/rules/rules_score/src/sphinx_html_merge.py index 14904ba6..7f597604 100755 --- a/bazel/rules/rules_score/src/sphinx_html_merge.py +++ b/bazel/rules/rules_score/src/sphinx_html_merge.py @@ -138,13 +138,16 @@ def copy_tree(src, dst, rel_path): copy_tree(src_path, dst_path, Path(".")) -def merge_html_dirs(output_dir, main_html_dir, dependencies): +def merge_html_dirs(output_dir, main_html_dir, dependencies, extra_static=None): """Merge HTML directories. Args: output_dir: Target output directory main_html_dir: Main module's HTML directory to copy as-is dependencies: List of (name, path) tuples for dependency modules + extra_static: List of (src_file, dest_subpath) tuples for extra files to + place in output/_static/. These are copied AFTER the main + HTML so they overwrite any theme-provided files if needed. """ output_path = Path(output_dir) @@ -152,6 +155,13 @@ def merge_html_dirs(output_dir, main_html_dir, dependencies): logging.info("Copying main HTML from %s to %s", main_html_dir, output_dir) copy_html_files(main_html_dir, output_dir) + # Copy any extra static files into output/_static/ + for src_file, dest_subpath in extra_static or []: + dst = output_path / "_static" / dest_subpath + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst) + logging.info("Copied extra static %s → _static/%s", src_file, dest_subpath) + # Collect all dependency names for link fixing and exclusion dep_names = [name for name, _ in dependencies] @@ -187,6 +197,13 @@ def main(): metavar="NAME:PATH", help="Dependency HTML directory in format NAME:PATH", ) + parser.add_argument( + "--extra-static", + action="append", + default=[], + metavar="SRC:SUBPATH", + help="Extra file to place in output/_static/. Format: SRC_FILE:DEST_SUBPATH", + ) parser.add_argument( "--log-level", choices=["error", "warn", "info", "debug"], @@ -212,8 +229,19 @@ def main(): name, path = dep_spec.split(":", 1) dependencies.append((name, path)) + # Parse extra static files + extra_static = [] + for spec in args.extra_static: + if ":" not in spec: + logging.error( + "Invalid --extra-static format '%s', expected SRC:SUBPATH", spec + ) + return 1 + src, subpath = spec.split(":", 1) + extra_static.append((src, subpath)) + # Merge the HTML directories - merge_html_dirs(args.output, args.main, dependencies) + merge_html_dirs(args.output, args.main, dependencies, extra_static=extra_static) logging.info("Successfully merged HTML into %s", args.output) return 0 diff --git a/bazel/rules/rules_score/templates/conf.template.py b/bazel/rules/rules_score/templates/conf.template.py index 49f9f283..0646a342 100644 --- a/bazel/rules/rules_score/templates/conf.template.py +++ b/bazel/rules/rules_score/templates/conf.template.py @@ -153,6 +153,10 @@ plantuml = f"{plantuml_path} -Playout=smetana" plantuml_output_format = "svg_obj" +import shutil as _shutil + +graphviz_dot = os.environ.get("GRAPHVIZ_DOT") or _shutil.which("dot") or "dot" + # HTML theme html_theme = "sphinx_rtd_theme" diff --git a/tools/lobster_rst_report/_helpers.py b/tools/lobster_rst_report/_helpers.py index 25e4f212..59085012 100644 --- a/tools/lobster_rst_report/_helpers.py +++ b/tools/lobster_rst_report/_helpers.py @@ -24,6 +24,8 @@ from typing import Dict, Tuple +import os + from lobster.common.report import Report from lobster.common.location import ( Void_Reference, @@ -32,6 +34,7 @@ Codebeamer_Reference, ) from lobster.common.items import Item, Requirement, Implementation, Activity +from .graphviz_utils import is_dot_available class RstUtils: @@ -176,6 +179,8 @@ def location_link(location, source_root: str = "") -> str: # lobster-trace: rst_req.RST_Source_Root_Prefix if isinstance(location, File_Reference): href = source_root + location.filename if source_root else location.filename + if location.line: + href += f"#L{location.line}" return f"`{e(location.to_string())} <{href}>`__" # lobster-trace: UseCases.Item_GitHub_Source @@ -339,9 +344,38 @@ def dot_escape(text: str) -> str: """ return text.replace("\\", "\\\\").replace('"', '\\"') + @classmethod + def _build_dot_lines(cls, report: Report) -> list: + """Return the raw DOT diagram lines (without the RST directive wrapper).""" + lines = [] + lines.append("digraph tracing_policy {") + lines.append(" rankdir=TB;") + lines.append( + ' node [shape=box, style=filled, fontname="Helvetica", margin="0.3,0.1"];' + ) + lines.append(" edge [arrowhead=open];") + lines.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) + lines.append(f' "{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) + lines.append(f' "{src}" -> "{dst}";') + lines.append("}") + return lines + @classmethod def build(cls, report: Report, indent: int = 0) -> list: - """Return RST lines for a ``.. graphviz::`` tracing-policy diagram. + """Return RST lines for the tracing-policy diagram. + + When Graphviz ``dot`` is available, emits a ``.. graphviz::`` directive + that Sphinx renders as an image. When ``dot`` is not found, emits a + ``.. note::`` explaining how to install Graphviz together with the raw + DOT source in a ``.. code-block::`` so the diagram can still be + visualised manually. Args: report: The loaded LOBSTER report whose ``config`` provides level @@ -356,28 +390,34 @@ def build(cls, report: Report, indent: int = 0) -> list: # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram indent_str = " " * indent nested_indent = indent_str + " " + dot_lines = cls._build_dot_lines(report) + + # Respect an explicit GRAPHVIZ_DOT env var set by the build system + # (e.g. via --action_env=GRAPHVIZ_DOT=/usr/bin/dot in CI). + dot_bin = os.environ.get("GRAPHVIZ_DOT") or None + if is_dot_available(dot_bin): + out = [] + out.append(f"{indent_str}.. graphviz::") + out.append("") + for dot_line in dot_lines: + out.append(f"{nested_indent}{dot_line}") + out.append("") + return out out = [] - out.append(f"{indent_str}.. graphviz::") + out.append(f"{indent_str}.. note::") 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"];' + f"{nested_indent}The tracing-policy diagram below could not be rendered " + f"because the Graphviz ``dot`` utility was not found." + ) + out.append( + f"{nested_indent}Install `Graphviz `__ and rebuild " + f"to see the diagram as an image." ) - 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(f"{nested_indent}.. code-block:: dot") + out.append("") + for dot_line in dot_lines: + out.append(f"{nested_indent} {dot_line}") out.append("") return out diff --git a/tools/lobster_rst_report/_renderers.py b/tools/lobster_rst_report/_renderers.py index a50fd37b..53847e2c 100644 --- a/tools/lobster_rst_report/_renderers.py +++ b/tools/lobster_rst_report/_renderers.py @@ -328,14 +328,11 @@ def build(self) -> list: class CoverageGridBuilder: - """Build the coverage summary section (table + policy diagram side by side). + """Build the coverage summary table. - 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. + Emits a plain ``.. list-table::`` with per-level coverage statistics and + links to each level. The tracing-policy diagram is emitted separately + (above this table) by the caller via :class:`PolicyDiagramBuilder`. Usage:: @@ -352,7 +349,7 @@ def __init__(self, report: Report): self._report = report def build(self, ref_fn) -> list: - """Return RST lines for the coverage grid. + """Return RST lines for the coverage table. Args: ref_fn: A callable ``(level_name: str) -> str`` returning an RST @@ -365,29 +362,25 @@ def build(self, ref_fn) -> list: # 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", + ".. list-table::", + " :header-rows: 1", + " :widths: 35 15 15 15", "", ] lines += [ - " * - Category", - " - Coverage", - " - OK Items", - " - Total Items", + " * - 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(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 diff --git a/tools/lobster_rst_report/rst_report.py b/tools/lobster_rst_report/rst_report.py index 10c59942..309c3ae3 100644 --- a/tools/lobster_rst_report/rst_report.py +++ b/tools/lobster_rst_report/rst_report.py @@ -42,7 +42,7 @@ from lobster.common.errors import LOBSTER_Error from .graphviz_utils import is_dot_available -from ._helpers import RstUtils, ItemNaming +from ._helpers import RstUtils, ItemNaming, PolicyDiagramBuilder from ._renderers import ( _KIND_ORDER, _build_page_map, @@ -89,7 +89,12 @@ def write_rst(report: Report, source_root: str = "") -> str: lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") lines.append("") - # Coverage table + tracing-policy diagram (rubric = not a TOC entry) + # Tracing-policy diagram (rubric = not a TOC entry) + lines.append(".. rubric:: Tracing Policy") + lines.append("") + lines += PolicyDiagramBuilder.build(report) + + # Coverage table (rubric = not a TOC entry) lines.append(".. rubric:: Coverage Summary") lines.append("") @@ -222,7 +227,12 @@ def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]: lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") lines.append("") - # Coverage table + policy diagram (rubric = not a TOC entry) + # Tracing-policy diagram (rubric = not a TOC entry) + lines.append(".. rubric:: Tracing Policy") + lines.append("") + lines += PolicyDiagramBuilder.build(report) + + # Coverage table (rubric = not a TOC entry) lines.append(".. rubric:: Coverage Summary") lines.append("") @@ -239,6 +249,9 @@ def ref_fn(n): # 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 + # The first toctree includes 'self' so the overview page appears as a + # navigation item alongside the per-level sub-pages. + first = True 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: @@ -246,7 +259,11 @@ def ref_fn(n): lines.append(".. toctree::") lines.append(f" :caption: {kind_title}") lines.append(" :maxdepth: 1") + lines.append(" :hidden:") lines.append("") + if first: + lines.append(" Overview ") + first = False for lv in levels_of_kind: lines.append(f" {page_map[lv.name]}") lines.append("")