diff --git a/bazel/rules/rules_score/private/architectural_design.bzl b/bazel/rules/rules_score/private/architectural_design.bzl index b8a1b69c..d423f845 100644 --- a/bazel/rules/rules_score/private/architectural_design.bzl +++ b/bazel/rules/rules_score/private/architectural_design.bzl @@ -32,19 +32,23 @@ load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_l # ============================================================================ def _run_puml_parser(ctx, puml_file): - """Run the PlantUML parser on a single .puml file to produce a FlatBuffers binary - and a lobster traceability file. + """Run the PlantUML parser on a single .puml file. - The diagram type is auto-detected by the parser and encoded in the - FlatBuffers schema (each diagram type uses its own root_type). - Lobster output is produced in-process for component diagrams. + Produces three output files: + - a FlatBuffers binary (``.fbs.bin``), + - a LOBSTER traceability file (``.lobster``), and + - an idmap sidecar (``.idmap.json``) used by the + ``clickable_plantuml`` Sphinx extension to resolve cross-diagram links. + + ``puml_file.short_path`` (workspace-relative) is passed as ``--source-name`` + so the idmap ``source`` field is a stable, path-unique identifier. Args: ctx: Rule context puml_file: The .puml File object to parse Returns: - Tuple of (fbs_output, lobster_output) declared output Files. + Tuple of (fbs_output, lobster_output, idmap_output) declared output Files. """ file_stem = puml_file.basename.rsplit(".", 1)[0] fbs_output = ctx.actions.declare_file( @@ -53,25 +57,32 @@ def _run_puml_parser(ctx, puml_file): lobster_output = ctx.actions.declare_file( "{}/{}.lobster".format(ctx.label.name, file_stem), ) + idmap_output = ctx.actions.declare_file( + "{}/{}.idmap.json".format(ctx.label.name, file_stem), + ) ctx.actions.run( inputs = [puml_file], - outputs = [fbs_output, lobster_output], + outputs = [fbs_output, lobster_output, idmap_output], executable = ctx.executable._puml_parser, arguments = [ "--file", puml_file.path, + "--source-name", + puml_file.short_path, "--fbs-output-dir", fbs_output.dirname, "--lobster-output-dir", lobster_output.dirname, + "--idmap-output-dir", + idmap_output.dirname, "--log-level", get_log_level(ctx), ], progress_message = "Parsing PlantUML diagram: %s" % puml_file.short_path, ) - return fbs_output, lobster_output + return fbs_output, lobster_output, idmap_output def _parse_puml_diagrams(ctx, files): """Run the PlantUML parser on all .puml/.plantuml files in a list. @@ -81,16 +92,18 @@ def _parse_puml_diagrams(ctx, files): files: List of File objects Returns: - Tuple of (fbs_outputs, lobster_outputs) lists of generated Files. + Tuple of (fbs_outputs, lobster_outputs, idmap_outputs) lists of generated Files. """ fbs_outputs = [] lobster_outputs = [] + idmap_outputs = [] for f in files: if f.extension in ("puml", "plantuml"): - fbs, lobster = _run_puml_parser(ctx, f) + fbs, lobster, idmap = _run_puml_parser(ctx, f) fbs_outputs.append(fbs) lobster_outputs.append(lobster) - return fbs_outputs, lobster_outputs + idmap_outputs.append(idmap) + return fbs_outputs, lobster_outputs, idmap_outputs def _run_validation(ctx, component_fbs_files, sequence_fbs_files, internal_api_fbs_files): """Run the architectural-design validation profile. @@ -138,45 +151,25 @@ def _architectural_design_impl(ctx): """ # Parse each architectural view separately so each provider field carries - # the flatbuffers for its own category. - static_fbs_list, static_lobster_list = _parse_puml_diagrams(ctx, ctx.files.static) - dynamic_fbs_list, dynamic_lobster_list = _parse_puml_diagrams(ctx, ctx.files.dynamic) - public_api_fbs_list, public_api_lobster_list = _parse_puml_diagrams(ctx, ctx.files.public_api) - internal_api_fbs_list, _internal_api_lobster_list = _parse_puml_diagrams(ctx, ctx.files.internal_api) + # the flatbuffers (and idmap sidecars) for its own category. + static_fbs_list, static_lobster_list, static_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static) + dynamic_fbs_list, dynamic_lobster_list, dynamic_idmap_list = _parse_puml_diagrams(ctx, ctx.files.dynamic) + public_api_fbs_list, public_api_lobster_list, public_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.public_api) + internal_api_fbs_list, _internal_api_lobster_list, internal_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.internal_api) static_fbs = depset(static_fbs_list) dynamic_fbs = depset(dynamic_fbs_list) public_api_fbs = depset(public_api_fbs_list) internal_api_fbs = depset(internal_api_fbs_list) public_api_lobster = depset(public_api_lobster_list) + all_idmaps = depset(static_idmap_list + dynamic_idmap_list + public_api_idmap_list + internal_api_idmap_list) # Source files for SphinxSourcesInfo (sphinx documentation pipeline) all_source_files = depset( transitive = [depset(ctx.files.static), depset(ctx.files.dynamic), depset(ctx.files.public_api), depset(ctx.files.internal_api)], ) - # Run the linker on all generated .fbs.bin files to produce a - # plantuml_links.json for the clickable_plantuml Sphinx extension. - all_fbs_files = static_fbs.to_list() + dynamic_fbs.to_list() + public_api_fbs.to_list() + internal_api_fbs.to_list() - plantuml_links_json = ctx.actions.declare_file( - "{}/plantuml_links.json".format(ctx.label.name), - ) - if all_fbs_files: - ctx.actions.run( - inputs = all_fbs_files, - outputs = [plantuml_links_json], - executable = ctx.executable._linker, - arguments = ["--fbs-files"] + [f.path for f in all_fbs_files] + ["--output", plantuml_links_json.path, "--log-level", get_log_level(ctx)], - progress_message = "Generating PlantUML links JSON for %s" % ctx.label.name, - ) - else: - ctx.actions.write( - output = plantuml_links_json, - content = '{"links":[]}', - ) - sphinx_files = depset( - [plantuml_links_json], transitive = [all_source_files], ) @@ -199,7 +192,9 @@ def _architectural_design_impl(ctx): sphinx_srcs = depset(rst_wrappers, transitive = [sphinx_files]) return [ - DefaultInfo(files = depset([validation_log.file], transitive = [all_source_files])), + # DefaultInfo intentionally carries only authored source artifacts. + # idmap sidecars are Sphinx-only auxiliaries exposed via SphinxSourcesInfo. + DefaultInfo(files = all_source_files), ArchitecturalDesignInfo( static = static_fbs, dynamic = dynamic_fbs, @@ -208,11 +203,12 @@ def _architectural_design_impl(ctx): public_api_lobster_files = public_api_lobster, validation_logs = [validation_log], ), - # Source diagram files + plantuml_links.json for the sphinx documentation build + # Source diagrams are regular srcs/deps; .idmap.json sidecars are aux files + # needed by clickable_plantuml and must not become top-level toctree entries. SphinxSourcesInfo( srcs = sphinx_srcs, deps = sphinx_srcs, - aux_srcs = depset(), + aux_srcs = all_idmaps, ), ] @@ -220,65 +216,56 @@ def _architectural_design_impl(ctx): # Rule Definition # ============================================================================ -def _architectural_design_attrs(): - attrs = { - "static": attr.label_list( - allow_files = [".puml", ".plantuml", ".svg", ".rst", ".md"], - mandatory = False, - doc = "Static architecture diagrams (class diagrams, component diagrams, etc.)", - ), - "dynamic": attr.label_list( - allow_files = [".puml", ".plantuml", ".svg", ".rst", ".md"], - mandatory = False, - doc = "Dynamic architecture diagrams (sequence diagrams, activity diagrams, etc.)", - ), - "public_api": attr.label_list( - allow_files = [".puml", ".plantuml"], - mandatory = False, - doc = "Public API diagrams (parsed identically to static/dynamic). " + - "Classified separately so their lobster items are exposed via " + - "public_api_lobster_files, enabling failure-mode-to-interface " + - "traceability at the dependable element level.", - ), - "internal_api": attr.label_list( - allow_files = [".puml", ".plantuml"], - mandatory = False, - doc = "Internal API diagrams (class diagrams). " + - "Classified separately so their FlatBuffers outputs are exposed via " + - "ArchitecturalDesignInfo.internal_api for downstream validation.", - ), - "maturity": attr.string( - default = "release", - values = ["release", "development"], - doc = "Maturity level of the architectural design. 'release' treats validation findings as errors; 'development' emits warnings and continues.", - ), - "_puml_parser": attr.label( - default = Label("@score_tooling//plantuml/parser:parser"), - executable = True, - cfg = "exec", - doc = "PlantUML parser tool that generates FlatBuffers from .puml files", - ), - "_linker": attr.label( - default = Label("@score_tooling//plantuml/parser:linker"), - executable = True, - cfg = "exec", - doc = "Tool that generates plantuml_links.json from FlatBuffers diagram outputs", - ), - "_puml_rst_template": attr.label( - default = Label("//bazel/rules/rules_score:templates/puml_diagram.template.rst"), - allow_single_file = True, - doc = "RST template for PlantUML diagram wrapper pages.", - ), - } - attrs.update(VALIDATION_ATTRS) - attrs.update(VERBOSITY_ATTR) - return attrs - _architectural_design = rule( implementation = _architectural_design_impl, doc = "Collects architectural design documents and diagrams for S-CORE process compliance. " + "Automatically parses PlantUML files to produce FlatBuffers binary representations.", - attrs = _architectural_design_attrs(), + attrs = dict( + { + "static": attr.label_list( + allow_files = [".puml", ".plantuml", ".svg", ".rst", ".md"], + mandatory = False, + doc = "Static architecture diagrams (class diagrams, component diagrams, etc.)", + ), + "dynamic": attr.label_list( + allow_files = [".puml", ".plantuml", ".svg", ".rst", ".md"], + mandatory = False, + doc = "Dynamic architecture diagrams (sequence diagrams, activity diagrams, etc.)", + ), + "public_api": attr.label_list( + allow_files = [".puml", ".plantuml"], + mandatory = False, + doc = "Public API diagrams (parsed identically to static/dynamic). " + + "Classified separately so their lobster items are exposed via " + + "public_api_lobster_files, enabling failure-mode-to-interface " + + "traceability at the dependable element level.", + ), + "internal_api": attr.label_list( + allow_files = [".puml", ".plantuml"], + mandatory = False, + doc = "Internal API diagrams (class diagrams). " + + "Classified separately so their FlatBuffers outputs are exposed via " + + "ArchitecturalDesignInfo.internal_api for downstream validation.", + ), + "maturity": attr.string( + default = "release", + values = ["release", "development"], + doc = "Maturity level of the architectural design. 'release' treats validation findings as errors; 'development' emits warnings and continues.", + ), + "_puml_parser": attr.label( + default = Label("@score_tooling//plantuml/parser:puml_cli"), + executable = True, + cfg = "exec", + doc = "PlantUML parser tool that generates FlatBuffers from .puml files", + ), + "_puml_rst_template": attr.label( + default = Label("//bazel/rules/rules_score:templates/puml_diagram.template.rst"), + allow_single_file = True, + doc = "RST template for PlantUML diagram wrapper pages.", + ), + }, + **dict(VALIDATION_ATTRS, **VERBOSITY_ATTR) + ), ) # ============================================================================ diff --git a/bazel/rules/rules_score/private/fmea.bzl b/bazel/rules/rules_score/private/fmea.bzl index b5faf943..0b1786a9 100644 --- a/bazel/rules/rules_score/private/fmea.bzl +++ b/bazel/rules/rules_score/private/fmea.bzl @@ -69,7 +69,7 @@ def _process_root_causes(ctx): ``//tools/sphinx:sphinx-build``), so the metamodel is not staged here. Returns: - Tuple ``(diagram_aux_files, root_causes_lobster_or_None, chains_json)``. + Tuple ``(diagram_aux_files, root_causes_idmaps, root_causes_lobster_or_None, chains_json)``. ``diagram_aux_files`` (the staged ``.puml`` diagrams) is empty when there are no PlantUML inputs; ``chains_json`` is always a File (an empty ``[]`` array when there are no diagrams). @@ -86,32 +86,52 @@ def _process_root_causes(ctx): # No fault trees: emit an empty chains file so the assembler still runs # (rendering every failure mode without an FTA). ctx.actions.write(chains_json, "[]\n") - return [], None, chains_json + return [], [], None, chains_json - # Symlink each authored diagram next to fmea.rst so ``.. uml:: `` - # resolves in the Sphinx tree. + # Symlink each authored diagram next to fmea.rst so .. uml:: + # resolves in the Sphinx tree. Basenames must be unique because staging is flat. diagram_aux_files = [] + seen_basenames = {} + duplicate_basenames = [] for src in puml_inputs: - staged = ctx.actions.declare_file("{}/{}".format(ctx.label.name, src.basename)) - ctx.actions.symlink(output = staged, target_file = src) - diagram_aux_files.append(staged) + if src.basename in seen_basenames: + duplicate_basenames.append(src.basename) + else: + seen_basenames[src.basename] = True + staged = ctx.actions.declare_file("{}/{}".format(ctx.label.name, src.basename)) + ctx.actions.symlink(output = staged, target_file = src) + diagram_aux_files.append(staged) + + if duplicate_basenames: + fail( + "root_causes contains duplicate basenames not supported by fmea staging: {}. " + .format(", ".join(duplicate_basenames)) + + "Rename diagrams to unique basenames.", + ) root_causes_lobster = ctx.actions.declare_file("{}/root_causes.lobster".format(ctx.label.name)) + root_cause_idmaps = [ + ctx.actions.declare_file( + "{}/{}.idmap.json".format(ctx.label.name, src.basename.rsplit(".", 1)[0]), + ) + for src in puml_inputs + ] args = ctx.actions.args() for src in puml_inputs: args.add("--file", src.path) args.add("--fta-output-dir", root_causes_lobster.dirname) + args.add("--idmap-output-dir", root_causes_lobster.dirname) args.add("--log-level", get_log_level(ctx)) ctx.actions.run( inputs = puml_inputs, - outputs = [root_causes_lobster, chains_json], + outputs = [root_causes_lobster, chains_json] + root_cause_idmaps, executable = ctx.executable._puml_cli, arguments = [args], progress_message = "Processing root cause FTA diagrams for %s" % ctx.label.name, ) - return diagram_aux_files, root_causes_lobster, chains_json + return diagram_aux_files, root_cause_idmaps, root_causes_lobster, chains_json # ============================================================================ # Lobster (TRLC traceability) helper @@ -142,8 +162,9 @@ def _fmea_impl(ctx): output_files = [] # 0. FTA: extract chains/lobster + stage diagrams (and metamodel) for rendering. - diagram_aux_files, root_causes_lobster, chains_json = _process_root_causes(ctx) + diagram_aux_files, root_cause_idmaps, root_causes_lobster, chains_json = _process_root_causes(ctx) output_files.extend(diagram_aux_files) + output_files.extend(root_cause_idmaps) # 1. Assemble fmea.rst from the chains + TRLC records (single in-process parse). fmea_rst = ctx.actions.declare_file("{}/fmea.rst".format(ctx.label.name)) @@ -207,7 +228,7 @@ def _fmea_impl(ctx): SphinxSourcesInfo( srcs = sphinx_srcs, deps = depset(transitive = [sphinx_srcs]), - aux_srcs = depset(diagram_aux_files), + aux_srcs = depset(diagram_aux_files + root_cause_idmaps), ), ] diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 345ac9ab..19df3489 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -14,7 +14,6 @@ # Helpers # ====================================================================================== load("@bazel_skylib//lib:paths.bzl", "paths") -load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") load("@rules_python//sphinxdocs/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo") load("//bazel/rules/rules_score:providers.bzl", "FilteredExecpathInfo", "SphinxIndexFileInfo", "SphinxModuleInfo", "SphinxNeedsInfo") load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_log_level") @@ -169,7 +168,6 @@ def _score_html_impl(ctx): Phase 1: Generate needs.json for this module and collect from all deps Phase 2: Generate HTML with external needs and merge all dependency HTML """ - run_args = [] # Copy of the args to forward along to debug runner args = ctx.actions.args() # Args passed to the action # Expand location references in extra_opts and collect as sphinx arguments. @@ -183,16 +181,12 @@ def _score_html_impl(ctx): for target in ctx.attr.extra_opts_targets: info = target[FilteredExecpathInfo] args.add(info.arg) - run_args.append(info.arg) filtered_files.append(info.matched_file) for opt in ctx.attr.extra_opts: # Standard extra_opts: expand locations and pass through expanded_opt = ctx.expand_location(opt, targets = location_targets) args.add(expanded_opt) - run_args.append(expanded_opt) - # Collect all transitive dependencies with deduplication - modules = [] sphinx_toolchain = ctx.toolchains["//bazel/rules/rules_score:toolchain_type"].sphinxinfo needs_external_needs = {} for dep in ctx.attr.needs: @@ -205,9 +199,6 @@ def _score_html_impl(ctx): "css_class": "", "version": "1.0", } - for dep in ctx.attr.deps: - if SphinxModuleInfo in dep: - modules.extend([dep[SphinxModuleInfo].html_dir]) needs_external_needs_json = ctx.actions.declare_file(ctx.label.name + "/needs_external_needs.json") ctx.actions.write( output = needs_external_needs_json, @@ -217,24 +208,53 @@ def _score_html_impl(ctx): # Materialize a file under the `_sources` dir def _relocate(source_file, dest_path = None): - if not dest_path: - dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix) - dest_path = paths.join(source_prefix, dest_path) + identity_preserving = ( + not dest_path and + (source_file.short_path.endswith(".puml") or source_file.short_path.endswith(".idmap.json")) + ) + + compat_dest_path = None + if identity_preserving: + # Preserve diagram/idmap identity under srcdir: + # staged-relative path (from srcdir) == short_path. + dest_path = paths.join(source_prefix, source_file.short_path) + + # Keep docs-relative .puml path for existing ``.. uml::`` includes. + if source_file.short_path.endswith(".puml"): + compat_dest_path = paths.join( + source_prefix, + source_file.short_path.removeprefix(ctx.attr.strip_prefix), + ) + else: + if not dest_path: + dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix) + dest_path = paths.join(source_prefix, dest_path) + if source_file.is_directory: dest_file = ctx.actions.declare_directory(dest_path) else: dest_file = ctx.actions.declare_file(dest_path) + + # Symlink all staged source files/directories into the synthetic source + # tree; path canonicalization is handled by clickable_plantuml. ctx.actions.symlink( output = dest_file, target_file = source_file, progress_message = "Symlinking Sphinx source %{input} to %{output}", ) sphinx_source_files.append(dest_file) + + if compat_dest_path and compat_dest_path != dest_path: + compat_file = ctx.actions.declare_file(compat_dest_path) + ctx.actions.symlink( + output = compat_file, + target_file = source_file, + progress_message = "Symlinking compatibility Sphinx source %{input} to %{output}", + ) + sphinx_source_files.append(compat_file) + return dest_file - for dep in ctx.attr.deps: - if SphinxModuleInfo in dep: - modules.extend([dep[SphinxModuleInfo].html_dir]) for t in ctx.attr.docs_library_deps: info = t[SphinxDocsLibraryInfo] for entry in info.transitive.to_list(): diff --git a/bazel/rules/rules_score/private/unit_design.bzl b/bazel/rules/rules_score/private/unit_design.bzl index afcf1bb1..10861a31 100644 --- a/bazel/rules/rules_score/private/unit_design.bzl +++ b/bazel/rules/rules_score/private/unit_design.bzl @@ -32,36 +32,46 @@ load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_l # ============================================================================ def _run_puml_parser(ctx, puml_file): - """Run the PlantUML parser on one .puml file and emit a FlatBuffers file.""" + """Run the PlantUML parser once and emit FlatBuffers + idmap sidecar.""" file_stem = puml_file.basename.rsplit(".", 1)[0] fbs_output = ctx.actions.declare_file( "{}/{}.fbs.bin".format(ctx.label.name, file_stem), ) + idmap_output = ctx.actions.declare_file( + "{}/{}.idmap.json".format(ctx.label.name, file_stem), + ) ctx.actions.run( inputs = [puml_file], - outputs = [fbs_output], + outputs = [fbs_output, idmap_output], executable = ctx.executable._puml_parser, arguments = [ "--file", puml_file.path, + "--source-name", + puml_file.short_path, "--fbs-output-dir", fbs_output.dirname, + "--idmap-output-dir", + idmap_output.dirname, "--log-level", get_log_level(ctx), ], progress_message = "Parsing Unit Design PlantUML diagram: %s" % puml_file.short_path, ) - return fbs_output + return fbs_output, idmap_output def _parse_puml_diagrams(ctx, files): - """Run parser on all .puml/.plantuml files from a list and return fbs outputs.""" + """Run parser on all .puml/.plantuml files and return fbs + idmap outputs.""" fbs_outputs = [] + idmap_outputs = [] for f in files: if f.extension in ("puml", "plantuml"): - fbs_outputs.append(_run_puml_parser(ctx, f)) - return fbs_outputs + fbs, idmap = _run_puml_parser(ctx, f) + fbs_outputs.append(fbs) + idmap_outputs.append(idmap) + return fbs_outputs, idmap_outputs def _unit_design_impl(ctx): """Implementation for unit_design rule. @@ -78,12 +88,15 @@ def _unit_design_impl(ctx): List of providers including DefaultInfo, UnitDesignInfo, SphinxSourcesInfo """ + static_fbs_list, static_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static) + dynamic_fbs_list, dynamic_idmap_list = _parse_puml_diagrams(ctx, ctx.files.dynamic) + all_source_files = depset( - transitive = [depset(ctx.files.static), depset(ctx.files.dynamic)], + ctx.files.static + ctx.files.dynamic + static_idmap_list + dynamic_idmap_list, ) - static_fbs = depset(_parse_puml_diagrams(ctx, ctx.files.static)) - dynamic_fbs = depset(_parse_puml_diagrams(ctx, ctx.files.dynamic)) + static_fbs = depset(static_fbs_list) + dynamic_fbs = depset(dynamic_fbs_list) return [ DefaultInfo(files = all_source_files), @@ -123,7 +136,7 @@ _unit_design = rule( default = Label("//plantuml/parser:parser"), executable = True, cfg = "exec", - doc = "PlantUML parser tool that generates FlatBuffers from .puml files", + doc = "PlantUML parser tool that generates FlatBuffers and .idmap.json files from .puml files", ), }, **VERBOSITY_ATTR