diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 47792d8e..be53a47d 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -114,6 +114,59 @@ py_binary( ], ) +# AoU chain-forwarding filter for raw TRLC source records (companion to +# aou_forwarding_to_lobster, operating on .trlc sources instead of .lobster +# JSON) -- used by dependable_element to expose forwarded AoUs' TRLC records +# via TrlcProviderInfo. +py_binary( + name = "filter_forwarded_trlc", + srcs = [ + "src/aou_forwarding_to_lobster.py", + "src/filter_forwarded_trlc.py", + "src/trlc_record_utils.py", + ], + imports = ["src"], + main = "src/filter_forwarded_trlc.py", + visibility = ["//visibility:public"], + deps = [ + "@lobster//lobster/common", + requirement("pyyaml"), + ], +) + +# Retypes a dependable_element's own AoU TRLC records for first-hop external +# exposure (companion to filter_forwarded_trlc.py, which does the same +# retyping for AoUs received from a dependency and chain-forwarded further) +# -- used by dependable_element so a raw ScoreReq.AoU record never leaves the +# TRLC compilation of the assumptions_of_use target that authored it. +py_binary( + name = "expose_own_aou_trlc", + srcs = [ + "src/expose_own_aou_trlc.py", + "src/trlc_record_utils.py", + ], + imports = ["src"], + main = "src/expose_own_aou_trlc.py", + visibility = ["//visibility:public"], +) + +# Deduplicates AoU/ReceivedAoU TRLC records that reach the same consumer via +# more than one path (e.g. a requirements target depending both directly on +# an AoU's original owner and on an intermediate dependable_element that +# chain-forwards that same AoU) -- same package + record name is retained +# deliberately (see filter_forwarded_trlc.py), so without this step such a +# diamond dependency shape would trip TRLC's own duplicate-definition check. +py_binary( + name = "dedupe_aou_trlc", + srcs = [ + "src/dedupe_aou_trlc.py", + "src/trlc_record_utils.py", + ], + imports = ["src"], + main = "src/dedupe_aou_trlc.py", + visibility = ["//visibility:public"], +) + # HTML merge tool py_library( name = "sphinx_html_merge_lib", diff --git a/bazel/rules/rules_score/docs/rule_reference.rst b/bazel/rules/rules_score/docs/rule_reference.rst index ff28409a..8362f81f 100644 --- a/bazel/rules/rules_score/docs/rule_reference.rst +++ b/bazel/rules/rules_score/docs/rule_reference.rst @@ -817,7 +817,7 @@ and scope checks at build/test time. * - Target - Purpose * - ```` - - Main target: build runs Sphinx; ``bazel test`` runs the traceability check + - Main target: build runs Sphinx; ``bazel test`` runs the traceability check. Also provides ``TrlcProviderInfo`` (``@trlc//:trlc.bzl``), aggregating this element's own AoU TRLC records (retyped from the original ``AoU`` to ``ScoreReq.ReceivedAoU``, same package + record name, with a fixed generic ``justification`` field injected -- this retyping only applies to what this ``TrlcProviderInfo`` re-exposes; a target depending directly on the original ``assumptions_of_use`` target still gets the true, unmodified ``AoU`` record) with the ``ScoreReq.ReceivedAoU`` records synthesized (retyped from the original ``AoU``/``ReceivedAoU``, same package + record name, with a ``justification`` field injected from ``aou_forwarding.yaml``) for anything it chain-forwards (see ``aou_forwarding`` above) — a downstream ``component_requirements``/``feature_requirements``/``assumed_system_requirements`` target can list this label directly in its own ``deps`` to resolve a ``derived_from`` reference to one of those AoUs, instead of needing direct visibility to the original ``assumptions_of_use`` target. ``deps`` on this provider is always an empty depset: AoU/ReceivedAoU records have no typed cross-reference fields of their own to resolve. * - ``_doc`` - Internal ``sphinx_module`` target; usable as ``deps`` in other Sphinx builds * - ``_index`` diff --git a/bazel/rules/rules_score/docs/user_guide/assumptions_of_use.rst b/bazel/rules/rules_score/docs/user_guide/assumptions_of_use.rst index 93d84e5c..e965f4b8 100644 --- a/bazel/rules/rules_score/docs/user_guide/assumptions_of_use.rst +++ b/bazel/rules/rules_score/docs/user_guide/assumptions_of_use.rst @@ -114,11 +114,22 @@ the ``CompReq`` that implements it, alongside any ``FeatReq``/ Two things are required for the reference to resolve: 1. ``import`` the AoU's package, same as any other TRLC cross-reference. -2. List the ``assumptions_of_use`` target that defines (or, for a received/ - forwarded AoU, originally defined) the record in the - ``component_requirements`` target's ``deps``. This target provides - TrlcProviderInfo, so it can be listed directly -- no intermediate wrapper - is needed. +2. List, in the ``component_requirements`` target's ``deps``, either: + + - the ``assumptions_of_use`` target that defines (or, for a received/ + forwarded AoU, originally defined) the record, **or** + - the ``dependable_element`` you already depend on that owns or + chain-forwards it. Every ``dependable_element`` also provides + ``TrlcProviderInfo``, aggregating its own AoU records (retyped to + ``ReceivedAoU`` for this external exposure -- see below) with the + ``ReceivedAoU`` records it synthesizes for anything it + chain-forwards via ``aou_forwarding`` (see below) -- so a downstream + requirements target does not need direct visibility to the AoU's + ultimate origin several ``deps`` hops away; it only needs to depend on + the ``dependable_element`` immediately in front of it. + + Either way, no intermediate wrapper target is needed -- both kinds of + label already provide ``TrlcProviderInfo`` directly. .. code-block:: text :caption: examples/integrator/docs/requirements/component_requirements.trlc @@ -145,8 +156,7 @@ Two things are required for the reference to resolve: testonly = True, deps = [ ":feature_requirements", - "@seooc//docs:sample_aous", - "@some_other_library//:other_library_aous", + "@seooc//:safety_software_seooc_example", ], ) @@ -156,6 +166,66 @@ TRLC parser itself at build time, not by a later lobster-report matching step -- while the resulting lobster item is still tagged and traced exactly as before, so the coverage report is unaffected. +**Why every externally-exposed AoU is synthesized as ``ReceivedAoU``, not +the raw ``AoU``** +A downstream target can always resolve a ``derived_from`` reference to an +AoU by depending directly on the ``assumptions_of_use`` target that +authored it -- that gets the true, unmodified ``AoU`` record and is the +normal, fully-linked way to consume an AoU, unaffected by anything below. +What must never happen is that raw ``AoU`` record being re-exposed, +verbatim, through a *``dependable_element``'s own* ``TrlcProviderInfo`` -- +used by anything that depends on the ``dependable_element`` label instead +of the ``assumptions_of_use`` target directly, precisely so it does not +need to know the AoU's true owner. Doing so verbatim would look, to any +tooling walking the consumer's requirements model, like a second, +independently authored assumption needing its own full +control-measure/safety-analysis linkage, when it is really just a +forwarding/exposure placeholder. Instead, every AoU a ``dependable_element`` +re-exposes through its own ``TrlcProviderInfo`` -- whether it is one of the +element's own (first-hop exposure) or one it received from a dependency and +is chain-forwarding further -- is *retyped* to ``ScoreReq.ReceivedAoU`` (a +distinct type, itself extending ``ControlMeasure`` like ``AoU``) with a +mandatory ``justification`` field injected: a fixed, generic notice for the +element's own AoUs (there is no per-AoU forwarding decision to source text +from -- an element's own AoUs are always exposed in full, unconditionally, +unlike chain-forwarding which is gated by ``aou_forwarding.yaml``), or the +``justification`` text carried over from the ``aou_forwarding.yaml`` entry +that authorized the forward. Critically, the retyped record keeps the +**exact same package and record name** as the original -- only its declared +type and the added ``justification`` field change -- so every +``derived_from = [Package.Name@version]`` reference written anywhere in the +chain keeps resolving unchanged, no matter how many hops away from the +original owner it is, or whether the element you depend on is the AoU's +original owner or a forwarder several hops downstream of it. + +**Diamond dependencies: automatic deduplication on consumption** +Preserving the AoU's original identity across every forwarding hop (and +across the very first hop of exposure) has one consequence that needs +handling: the same identity can legitimately reach a single TRLC parse via +more than one path -- e.g. a ``component_requirements`` target that lists +both the AoU's original owner and an intermediate ``dependable_element`` +that chain-forwards that same AoU directly in its own ``deps`` (a "diamond" +dependency shape). TRLC's own duplicate-definition check keys on +``package + record name`` alone, not on declared type, so without any +further handling this would be rejected as a duplicate definition. To +prevent this, both the point where a ``dependable_element`` collects what it +received from its own ``deps`` and the point where any +``feature_requirements``/``component_requirements``/ +``assumed_system_requirements``/``assumptions_of_use`` target merges +``TrlcProviderInfo`` across its own ``deps`` run a deduplication pass (see +``dedupe_aou_trlc.py`` / ``aou_trlc_dedupe.bzl``): whenever the same +``Package.RecordName`` AoU/ReceivedAoU identity is declared more than once +across the merged files, only one declaration is kept and the rest are +dropped before TRLC ever sees them. Since a raw ``AoU`` record can only ever +be legitimately authored once (by its true owner, internally) and never +leaves that owner's own compilation, any identity collision reachable +externally is, by construction, always the same original reached via a +different path -- never two independently-authored, unrelated AoUs that +happen to share a name. This runs automatically -- there is nothing to +configure -- and only ever touches ``AoU``/``ReceivedAoU`` records; a +genuine duplicate definition of any other record type is left alone and +still fails as a real authoring error. + **Example: three-level forwarding chain** (the real working code for this example lives in ``examples/some_other_library``, ``examples/seooc``, and ``examples/integrator``) diff --git a/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD b/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD index 9423d6f8..713c18f3 100644 --- a/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD +++ b/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD @@ -34,13 +34,20 @@ feature_requirements( component_requirements( name = "component_requirements", + testonly = True, srcs = [ "component_requirements.trlc", ], visibility = ["//visibility:public"], deps = [ ":feature_requirements", - "@seooc//docs:sample_aous", - "@some_other_library//:other_library_aous", + # A single dep on the immediate dependable_element dependency is + # enough to resolve both AoU references below -- it aggregates its + # own AoU (SampleType.SampleAoU) and the AoU it chain-forwards from + # its own dependency (OtherLibrary.TimingConstraint) via + # TrlcProviderInfo. No direct visibility to + # @some_other_library//:other_library_aous (the AoU's ultimate + # origin, several `deps` hops away) is needed. + "@seooc//:safety_software_seooc_example", ], ) diff --git a/bazel/rules/rules_score/lobster/config/lobster_aou.yaml b/bazel/rules/rules_score/lobster/config/lobster_aou.yaml index 88ffb22a..1859ada3 100644 --- a/bazel/rules/rules_score/lobster/config/lobster_aou.yaml +++ b/bazel/rules/rules_score/lobster/config/lobster_aou.yaml @@ -19,3 +19,8 @@ conversion-rules: namespace: req version-field: version description-fields: description + - package: ScoreReq + record-type: ReceivedAoU + namespace: req + version-field: version + description-fields: description diff --git a/bazel/rules/rules_score/private/aou_trlc_dedupe.bzl b/bazel/rules/rules_score/private/aou_trlc_dedupe.bzl new file mode 100644 index 00000000..ecd88740 --- /dev/null +++ b/bazel/rules/rules_score/private/aou_trlc_dedupe.bzl @@ -0,0 +1,81 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Shared Starlark helper wiring the ``dedupe_aou_trlc`` tool into a rule action. + +An AoU's TRLC identity (package + record name) is deliberately preserved +verbatim by ``filter_forwarded_trlc.py`` when it retypes a chain-forwarded +AoU to ``ScoreReq.ReceivedAoU`` -- this is what lets a ``derived_from`` +reference stay valid no matter how many hops of forwarding it has been +through. The unavoidable consequence is that the *same* AoU identity can +legitimately appear in more than one ``.trlc`` file that end up merged into a +single TRLC parse/check -- most commonly in a diamond dependency shape (a +target depends both directly on an AoU's original owner and, transitively, +on an intermediate element that chain-forwards that same AoU). TRLC's own +duplicate-definition check keys on ``(package, name)`` alone, not on +declared type, and rejects this outright. + +``dedupe_aou_trlc_files`` runs the ``dedupe_aou_trlc`` tool (see +``src/dedupe_aou_trlc.py``) over a list of files whenever there is more than +one, so any such duplicate AoU/ReceivedAoU identity is collapsed down to a +single declaration before those files are merged for a TRLC +parse/render/check. Used by both ``dependable_element.bzl`` (deduplicating +what it received from its own ``deps`` before chain-forwarding) and +``requirements.bzl`` (deduplicating what a `feature_requirements`/ +`component_requirements`/`assumed_system_requirements`/`assumptions_of_use` +target's own ``deps`` expose, which is where the diamond shape most commonly +surfaces for a *downstream* consumer). +""" + +def dedupe_aou_trlc_files(ctx, tool, files, output_subdir): + """Deduplicate AoU/ReceivedAoU TRLC records across a list of files. + + Args: + ctx: Rule context (used for ``ctx.actions`` and ``ctx.label``). + tool: ``executable`` File for the ``dedupe_aou_trlc`` tool (an + attribute resolved via ``ctx.executable.``). + files: List of ``File`` to deduplicate. Returned unchanged (no + action is run) if it has fewer than two entries -- a single + file cannot contain a cross-file duplicate. + output_subdir: Subdirectory name (relative to ``ctx.label.name``) + to declare the deduplicated output files under. Callers using + this helper more than once within the same rule implementation + must pass a distinct value each time to avoid output path + collisions. + + Returns: + A list of ``File``, order-aligned with ``files``: either ``files`` + itself unchanged (fewer than two entries), or a matching list of + freshly declared, deduplicated output files. + """ + if len(files) < 2: + return files + + outputs = [ + ctx.actions.declare_file("{}/{}/{}_{}".format(ctx.label.name, output_subdir, i, f.basename)) + for i, f in enumerate(files) + ] + + args = ctx.actions.args() + args.add_all("--inputs", files) + args.add_all("--outputs", outputs) + + ctx.actions.run( + inputs = files, + outputs = outputs, + executable = tool, + arguments = [args], + progress_message = "Deduplicating AoU TRLC records for %s" % ctx.label.name, + mnemonic = "AoUTrlcDedupe", + ) + + return outputs diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index a366ab36..08d07409 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -27,6 +27,7 @@ load( "subrule_lobster_report", ) load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") +load("@trlc//:trlc.bzl", "TrlcProviderInfo") load( "//bazel/rules/rules_score:providers.bzl", "ArchitecturalDesignInfo", @@ -39,13 +40,14 @@ load( "DependableElementInfo", "DependableElementLobsterInfo", "FeatureRequirementsInfo", - "ForwardedAoUInfo", + "ReceivedAoUInfo", "SphinxIndexFileInfo", "SphinxModuleInfo", "SphinxNeedsInfo", "SphinxSourcesInfo", "UnitInfo", ) +load("//bazel/rules/rules_score/private:aou_trlc_dedupe.bzl", "dedupe_aou_trlc_files") load( "//bazel/rules/rules_score/private:architecture_aspect.bzl", "CurrentArchitectureProviderInfo", @@ -1525,17 +1527,96 @@ def _dependable_element_index_impl(ctx): own_aou_lobster_depset = depset(transitive = own_aou_lobster_files) + # Collect this element's own AoU TRLC source records (spec + reqs only -- + # deliberately no deps; see ReceivedAoUInfo doc in providers.bzl for why + # AoU records never need one). assumptions_of_use targets already provide + # TrlcProviderInfo (they are the "aou" kind of score_requirements_rule). + # + # The raw records collected here are still ScoreReq.AoU as authored -- + # they must never be re-exposed verbatim through this dependable_element's + # own TrlcProviderInfo (same "dangling record with no linkage in this + # scope" problem filter_forwarded_trlc.py exists to avoid for + # chain-forwarded records, see expose_own_aou_trlc.py). A target depending + # directly on the assumptions_of_use target itself is unaffected and still + # resolves against the true, unmodified AoU. These records are retyped to + # ScoreReq.ReceivedAoU below, right before being exposed via this + # dependable_element's own_aou_trlc. + own_aou_trlc_spec = [] + own_aou_trlc_raw_reqs = [] + for aou_target in ctx.attr.assumptions_of_use: + if TrlcProviderInfo in aou_target: + own_aou_trlc_spec.append(aou_target[TrlcProviderInfo].spec) + own_aou_trlc_raw_reqs.append(aou_target[TrlcProviderInfo].reqs) + + own_aou_trlc_raw_reqs_list = depset(transitive = own_aou_trlc_raw_reqs).to_list() + + own_aou_trlc_reqs_depset = depset() + if own_aou_trlc_raw_reqs_list: + own_aou_trlc_exposed_files = [ + ctx.actions.declare_file( + "{}/own_aou_trlc/{}_{}".format(ctx.label.name, i, f.basename), + ) + for i, f in enumerate(own_aou_trlc_raw_reqs_list) + ] + own_aou_trlc_args = ctx.actions.args() + own_aou_trlc_args.add("--inputs") + own_aou_trlc_args.add_all(own_aou_trlc_raw_reqs_list) + own_aou_trlc_args.add("--outputs") + own_aou_trlc_args.add_all(own_aou_trlc_exposed_files) + ctx.actions.run( + inputs = own_aou_trlc_raw_reqs_list, + outputs = own_aou_trlc_exposed_files, + executable = ctx.executable._expose_own_aou_trlc_tool, + arguments = [own_aou_trlc_args], + progress_message = "Exposing own AoU TRLC records for %s" % ctx.label.name, + mnemonic = "OwnAoUTrlcExposure", + ) + own_aou_trlc_reqs_depset = depset(own_aou_trlc_exposed_files) + + own_aou_trlc = struct( + spec = depset(transitive = own_aou_trlc_spec), + reqs = own_aou_trlc_reqs_depset, + ) + # Collect forwarded AoU lobster files from deps (received AoUs) received_aou_lobster_files = [] for dep in ctx.attr.processed_deps: - if ForwardedAoUInfo in dep: - fwd_info = dep[ForwardedAoUInfo] + if ReceivedAoUInfo in dep: + fwd_info = dep[ReceivedAoUInfo] received_aou_lobster_files.append(fwd_info.own_aou_lobster) received_aou_lobster_files.append(fwd_info.chain_forwarded_lobster) received_aou_lobster_depset = depset(transitive = received_aou_lobster_files) received_aou_list = received_aou_lobster_depset.to_list() + # Collect received AoU TRLC source records from deps, mirroring the + # lobster collection above (own_aou_trlc + chain_forwarded_trlc from each + # dep's ReceivedAoUInfo). + received_aou_trlc_spec = [] + received_aou_trlc_reqs = [] + for dep in ctx.attr.processed_deps: + if ReceivedAoUInfo in dep: + fwd_info = dep[ReceivedAoUInfo] + received_aou_trlc_spec.append(fwd_info.own_aou_trlc.spec) + received_aou_trlc_spec.append(fwd_info.chain_forwarded_trlc.spec) + received_aou_trlc_reqs.append(fwd_info.own_aou_trlc.reqs) + received_aou_trlc_reqs.append(fwd_info.chain_forwarded_trlc.reqs) + + received_aou_trlc_spec_depset = depset(transitive = received_aou_trlc_spec) + + # Deduplicate before any further processing: this element's own deps may + # already form a diamond (e.g. it depends on both an AoU's original + # owner and another dep that already chain-forwards that same AoU), in + # which case the same Package.RecordName identity would otherwise appear + # twice in received_aou_trlc_reqs_list -- once as ScoreReq.AoU, once as + # an already-retyped ScoreReq.ReceivedAoU. See aou_trlc_dedupe.bzl. + received_aou_trlc_reqs_list = dedupe_aou_trlc_files( + ctx, + ctx.executable._dedupe_aou_trlc_tool, + depset(transitive = received_aou_trlc_reqs).to_list(), + "received_aou_trlc_dedup", + ) + # Chain-forwarding: if aou_forwarding YAML is provided, filter received AoUs. chain_forwarded_lobster_depset = depset() forwarded_aou_markers_list = [] @@ -1565,6 +1646,44 @@ def _dependable_element_index_impl(ctx): output_files.append(chain_forwarded_lobster_file) output_files.append(forwarded_aou_markers_file) + # Chain-forwarding at the TRLC level: same aou_forwarding YAML selection, + # applied to the received .trlc source files (1:1 filtered output per + # received input file -- see filter_forwarded_trlc.py for why). spec is + # unioned unfiltered (always the same shared RSL files regardless of + # which records are selected for forwarding). + chain_forwarded_trlc_reqs_depset = depset() + if ctx.file.aou_forwarding and received_aou_trlc_reqs_list: + # Prefix each output with its index: received files may come from + # different deps and share a basename (e.g. multiple upstream + # "assumptions_of_use.trlc"), so basename alone is not guaranteed + # unique within this target's output tree. + chain_forwarded_trlc_files = [ + ctx.actions.declare_file( + "{}/chain_forwarded_trlc/{}_{}".format(ctx.label.name, i, f.basename), + ) + for i, f in enumerate(received_aou_trlc_reqs_list) + ] + trlc_fwd_args = ctx.actions.args() + trlc_fwd_args.add("--yaml", ctx.file.aou_forwarding) + trlc_fwd_args.add("--inputs") + trlc_fwd_args.add_all(received_aou_trlc_reqs_list) + trlc_fwd_args.add("--outputs") + trlc_fwd_args.add_all(chain_forwarded_trlc_files) + ctx.actions.run( + inputs = [ctx.file.aou_forwarding] + received_aou_trlc_reqs_list, + outputs = chain_forwarded_trlc_files, + executable = ctx.executable._filter_forwarded_trlc_tool, + arguments = [trlc_fwd_args], + progress_message = "Filtering chain-forwarded AoU TRLC records for %s" % ctx.label.name, + mnemonic = "AoUTrlcForwarding", + ) + chain_forwarded_trlc_reqs_depset = depset(chain_forwarded_trlc_files) + + chain_forwarded_trlc = struct( + spec = received_aou_trlc_spec_depset, + reqs = chain_forwarded_trlc_reqs_depset, + ) + lobster_report_file = None lobster_html_report = None lobster_rst_dir = None @@ -1815,9 +1934,16 @@ def _dependable_element_index_impl(ctx): lobster_html_report = lobster_html_report, lobster_rst_dir = lobster_rst_dir, ), - ForwardedAoUInfo( + ReceivedAoUInfo( own_aou_lobster = own_aou_lobster_depset, chain_forwarded_lobster = chain_forwarded_lobster_depset, + own_aou_trlc = own_aou_trlc, + chain_forwarded_trlc = chain_forwarded_trlc, + ), + TrlcProviderInfo( + spec = depset(transitive = [own_aou_trlc.spec, chain_forwarded_trlc.spec]), + reqs = depset(transitive = [own_aou_trlc.reqs, chain_forwarded_trlc.reqs]), + deps = depset(), ), OutputGroupInfo(debug = depset(validation_output_files + unit_validation_output_files)), ] @@ -1922,6 +2048,24 @@ def _dependable_element_index_attrs(): cfg = "exec", doc = "Tool for filtering received AoU lobster entries based on chain-forwarding YAML.", ), + "_filter_forwarded_trlc_tool": attr.label( + default = Label("//bazel/rules/rules_score:filter_forwarded_trlc"), + executable = True, + cfg = "exec", + doc = "Tool for filtering received AoU TRLC source records based on chain-forwarding YAML (TRLC-level companion to _aou_forwarding_tool).", + ), + "_expose_own_aou_trlc_tool": attr.label( + default = Label("//bazel/rules/rules_score:expose_own_aou_trlc"), + executable = True, + cfg = "exec", + doc = "Tool for retyping a dependable_element's own AoU TRLC records for first-hop external exposure.", + ), + "_dedupe_aou_trlc_tool": attr.label( + default = Label("//bazel/rules/rules_score:dedupe_aou_trlc"), + executable = True, + cfg = "exec", + doc = "Tool for deduplicating AoU/ReceivedAoU TRLC records received via more than one dep path before chain-forwarding.", + ), "_test_runner": attr.label( default = Label("//bazel/rules/rules_score/src/test_case_coverage:test_runner"), executable = True, @@ -1940,7 +2084,7 @@ _dependable_element_index = rule( Despite the name, this is not merely an internal implementation detail: it is the actual cross-element provider surface. A dependable_element's `deps` on another dependable_element resolve to that element's - `_index` target (see `processed_deps` below), because ForwardedAoUInfo, + `_index` target (see `processed_deps` below), because ReceivedAoUInfo, CertifiedScope and DependableElementLobsterInfo are only returned here, not by the public `` target. """, @@ -2031,6 +2175,12 @@ def _dependable_element_impl(ctx): # integrity-level checks by parent dependable elements index_dep[CertifiedScope], index_dep[DependableElementInfo], + # TrlcProviderInfo: forwarded from index so a requirements()-style + # target (component_requirements, feature_requirements, ...) can + # list the public dependable_element label directly in its own + # `deps` and resolve `derived_from` references against this + # element's own or chain-forwarded AoU TRLC records. + index_dep[TrlcProviderInfo], ] + ([sphinx_dep[SphinxNeedsInfo]] if SphinxNeedsInfo in sphinx_dep else []) _dependable_element_test = rule( @@ -2146,7 +2296,7 @@ def dependable_element( Generated Targets: _index: Generates index.rst and copies artifacts. Also the actual - cross-element provider API — ForwardedAoUInfo, CertifiedScope and + cross-element provider API — ReceivedAoUInfo, CertifiedScope and DependableElementLobsterInfo are only exposed here, so a sibling dependable_element's `deps` are resolved against `_index` (see `processed_deps`), not against `` itself. diff --git a/bazel/rules/rules_score/private/requirements.bzl b/bazel/rules/rules_score/private/requirements.bzl index 49735d67..21ff0a82 100644 --- a/bazel/rules/rules_score/private/requirements.bzl +++ b/bazel/rules/rules_score/private/requirements.bzl @@ -22,6 +22,7 @@ public-facing macros. load("@lobster//:lobster.bzl", "subrule_lobster_trlc") load("@trlc//:trlc.bzl", "TrlcProviderInfo", "subrule_trlc_image_stage") load("//bazel/rules/rules_score:providers.bzl", "AssumedSystemRequirementsInfo", "AssumptionsOfUseInfo", "ComponentRequirementsInfo", "FeatureRequirementsInfo", "SphinxSourcesInfo") +load("//bazel/rules/rules_score/private:aou_trlc_dedupe.bzl", "dedupe_aou_trlc_files") load("//bazel/rules/rules_score/private:rst_to_trlc.bzl", "rst_to_trlc") _DEFAULT_SPEC = Label("//bazel/rules/rules_score/trlc/config:score_requirements_model") @@ -64,7 +65,20 @@ def _requirements_impl(ctx): own_spec_files = depset(transitive = [t[DefaultInfo].files for t in ctx.attr.spec]) spec_depset = depset(transitive = [own_spec_files] + transitive_spec) - deps_depset = depset(transitive = transitive_reqs) + + # Deduplicate before merging: an AoU's TRLC identity (package + record + # name) is deliberately preserved across chain-forwarding hops (see + # filter_forwarded_trlc.py / aou_trlc_dedupe.bzl), so a diamond shape -- + # this target listing both an AoU's original owner and another dep that + # (transitively) chain-forwards that same AoU -- would otherwise merge + # two declarations of the same identity into one TRLC parse, which + # TRLC's own duplicate-definition check rejects. + deps_depset = depset(dedupe_aou_trlc_files( + ctx, + ctx.executable._dedupe_aou_trlc_tool, + depset(transitive = transitive_reqs).to_list(), + "deps_dedup", + )) # All files needed for TRLC parsing: own sources + spec RSL + transitive deps. # This matches DefaultInfo.files of an equivalent trlc_requirements target so @@ -195,6 +209,12 @@ _score_requirements_rule = rule( cfg = "exec", doc = "TRLC-to-RST renderer tool.", ), + "_dedupe_aou_trlc_tool": attr.label( + default = Label("//bazel/rules/rules_score:dedupe_aou_trlc"), + executable = True, + cfg = "exec", + doc = "Tool for deduplicating AoU/ReceivedAoU TRLC records reachable via more than one deps entry (e.g. a diamond dependency on both an AoU's original owner and a forwarder of that same AoU).", + ), }, subrules = [subrule_lobster_trlc, subrule_trlc_image_stage], ) diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl index 7a612d1b..c8fa2c23 100644 --- a/bazel/rules/rules_score/providers.bzl +++ b/bazel/rules/rules_score/providers.bzl @@ -129,16 +129,85 @@ AssumptionsOfUseInfo = provider( }, ) -ForwardedAoUInfo = provider( - doc = """Carries AoU lobster files that dependees must satisfy. +ReceivedAoUInfo = provider( + doc = """Carries AoU lobster files (and their underlying TRLC records) that dependees must satisfy. When a dependable element is listed in another element's `deps`, the dependee receives this element's AoUs and must either link them in its lobster traceability report or further-forward them. + + In addition to the lobster-level fields (`own_aou_lobster`, + `chain_forwarded_lobster`), this provider also carries the raw TRLC + *source* records for the same AoUs (`own_aou_trlc`, `chain_forwarded_trlc`) + so that a dependee can list this dependable_element directly in a + `requirements()`-style target's `deps` and resolve a + `derived_from = [Pkg.SomeAoU@1]` cross-reference against an AoU it + received (own or chain-forwarded). + + `own_aou_trlc` / `chain_forwarded_trlc` are each a + `struct(spec = depset, reqs = depset)` -- deliberately **without** a + `deps` field. Checked against the S-CORE requirements model + (`trlc/config/score_requirements_model.rsl`): `AoU` and `ReceivedAoU` + both extend `ControlMeasure`, which only adds a free-text `mitigates` + field -- neither has a typed cross-reference field (unlike e.g. + `CompReq.derived_from` or `FeatReq.derived_from`), so an AoU/ReceivedAoU + record never needs any other TRLC file in scope to resolve a symbol + within itself beyond the shared RSL spec (which is always merged in by + default). AoU forwarding therefore never needs to carry a `deps` depset + -- this is a permanent, model-derived restriction, not a temporary + limitation. + + `chain_forwarded_trlc.reqs` never contains a verbatim copy of the + original `AoU` record, and neither does `own_aou_trlc.reqs`. A downstream + target can always resolve a `derived_from` reference to an AoU by + depending directly on the `assumptions_of_use` target that authored it + -- that gets the true, unmodified `ScoreReq.AoU` record and is the + normal, fully-linked way to consume an AoU. What must never happen is + that raw `AoU` record being re-exposed, verbatim, through a + *different* channel -- specifically, the aggregate `TrlcProviderInfo` + that a `dependable_element` itself provides (used by anything that + depends on the `dependable_element` label instead of the + `assumptions_of_use` target directly, precisely so it does not need to + know the AoU's true owner). Doing so verbatim would create the same + "dangling record with no linkage in this scope" problem + chain-forwarding retyping exists to avoid, from the perspective of + whatever tooling walks the *consumer's* requirements model. So every AoU + a `dependable_element` re-exposes through its own `TrlcProviderInfo`, + whether it is this element's own (first-hop exposure, `own_aou_trlc`, + via `expose_own_aou_trlc.py`) or one it received and is forwarding + further (`chain_forwarded_trlc`, via `filter_forwarded_trlc.py`), is + *retyped* to `ScoreReq.ReceivedAoU` (each carrying a `justification` + field -- a fixed generic notice for `own_aou_trlc`, or the + `aou_forwarding.yaml` entry's text for `chain_forwarded_trlc`) while + keeping the exact same package + record name, so `derived_from` + references written against the original identity keep resolving + unchanged no matter how many hops away from the original owner they + are. This avoids a raw duplicate `AoU` record being mistaken, by + tooling walking the requirements model, for a second, independently + authored assumption needing its own control-measure linkage, when it is + really just a forwarding/exposure placeholder. + + Preserving identity across hops means the same AoU can legitimately + reach one TRLC parse via more than one path (a "diamond": e.g. a + consumer depends both on this AoU's original owner and on an + intermediate element that chain-forwards it) -- both + `dependable_element.bzl` (when collecting `received_aou_trlc_reqs_list` + from `processed_deps`) and `requirements.bzl` (when merging + `TrlcProviderInfo` across a `deps` list) run the diamond dedupe pass + (`aou_trlc_dedupe.bzl` / `dedupe_aou_trlc.py`) to collapse any such + duplicate identity down to a single kept declaration before it ever + reaches TRLC. Since a raw `ScoreReq.AoU` record is never re-exposed + verbatim through any `dependable_element`'s own `TrlcProviderInfo`, + every identity collision reachable that way is, by construction, always + the same original reached via a different path -- never two + independently-authored, unrelated AoUs that coincidentally share a + name. """, fields = { "own_aou_lobster": "Depset of .lobster files from this element's own assumptions_of_use (always forwarded to dependees).", "chain_forwarded_lobster": "Depset of .lobster files for received AoUs being further-forwarded (selected via aou_forwarding YAML).", + "own_aou_trlc": "struct(spec = depset, reqs = depset) of this element's own AoU TRLC source records (always forwarded to dependees). Records are retyped from ScoreReq.AoU to ScoreReq.ReceivedAoU with a fixed generic justification field (see expose_own_aou_trlc.py), same package + record name preserved -- this only affects what is re-exposed through the dependable_element's own TrlcProviderInfo; a target depending directly on the assumptions_of_use target still gets the true, unmodified AoU record. No `deps` field -- AoU records have no typed cross-reference fields to resolve (see provider doc).", + "chain_forwarded_trlc": "struct(spec = depset, reqs = depset) of TRLC source records for received AoUs being further-forwarded (selected via aou_forwarding YAML, same selection as chain_forwarded_lobster). Records are retyped to ScoreReq.ReceivedAoU with an injected justification field, same package + record name preserved. No `deps` field -- see provider doc.", }, ) diff --git a/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py b/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py index 042da0d6..7b646886 100644 --- a/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py +++ b/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py @@ -44,6 +44,12 @@ GENERATOR = "aou_forwarding_to_lobster" +# Tracing_Tag namespace for synthetic "Forwarded AoUs" marker items (see +# build_forwarded_markers), distinct from "req" (used by the AoUs +# themselves): Tracing_Tag.key() = namespace + " " + tag, so a distinct +# namespace alone guarantees markers never collide with the real AoU tag. +_MARKER_NAMESPACE = "aou_forwarding_marker" + logger = logging.getLogger(__name__) _LEVEL_MAP = { @@ -211,12 +217,18 @@ def build_forwarded_markers( ) -> list[Requirement]: """Build synthetic "Forwarded AoUs" marker items for the DE's own report. - Each marker is a distinct lobster item (its own tag, so it does not - collide with the "Received AoUs" level in the same report) carrying a - `refs` entry pointing at the original received AoU tag. This gives - LOBSTER a `trace to: "Received AoUs"` edge for AoUs that are being - chain-forwarded rather than handled locally. The forwarding - justification becomes the marker's descriptive text. + A marker is not a copy of the AoU: it is a distinct bookkeeping record + meaning "this dependable_element decided to forward AoU X onward + instead of handling it locally", carrying a `refs` entry that points + at the original received AoU's tag. This gives LOBSTER a + `trace to: "Received AoUs"` edge for AoUs that are being chain- + forwarded rather than handled locally. The forwarding justification + becomes the marker's descriptive text. + + The marker's tag reuses the original AoU's tag text/version but under + a distinct ``_MARKER_NAMESPACE``, so ``Tracing_Tag.key()`` (namespace + + tag) never collides with the real AoU's tag despite both being loaded + into the same report. Args: forwarding_entries: Parsed YAML entries with 'aou_id' and @@ -235,7 +247,7 @@ def build_forwarded_markers( for entry, item in _match_forwarded_entries(forwarding_entries, lobster_items): aou_id = entry["aou_id"] marker = Requirement( - tag=Tracing_Tag("req", f"{aou_id}__forwarded"), + tag=Tracing_Tag(_MARKER_NAMESPACE, item.tag.tag, item.tag.version), location=File_Reference(yaml_path, line=1), framework="AoUForwarding", kind="ForwardedAoU", diff --git a/bazel/rules/rules_score/src/dedupe_aou_trlc.py b/bazel/rules/rules_score/src/dedupe_aou_trlc.py new file mode 100644 index 00000000..54e56efd --- /dev/null +++ b/bazel/rules/rules_score/src/dedupe_aou_trlc.py @@ -0,0 +1,217 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Deduplicate AoU/ReceivedAoU TRLC records reachable via more than one path. + +``filter_forwarded_trlc.py`` deliberately keeps a chain-forwarded AoU's +identity (package + record name) identical to the original ``AoU`` record it +was retyped from, so a ``derived_from`` reference written once keeps +resolving no matter how many hops of forwarding it travels through. This is +exactly what makes a *diamond* dependency shape dangerous: if the same +original AoU is reachable both directly (from its owning +``assumptions_of_use``/``dependable_element``) and indirectly (via one or +more intermediate elements that chain-forward it), the same +``Package.RecordName`` identity ends up declared in more than one ``.trlc`` +file that are simultaneously in scope for one TRLC check -- and TRLC's own +duplicate-definition check keys on ``(package, name)`` alone, not on +declared type, so it rejects this outright even though the two +declarations are, semantically, "the same AoU arriving twice." + +This tool runs wherever such a set of TRLC files is merged together -- +inside a ``dependable_element``'s own aggregation of what it received from +its ``deps`` (so its own re-exposed ``TrlcProviderInfo`` is never internally +inconsistent), and again in the shared ``requirements.bzl`` implementation +that merges ``TrlcProviderInfo`` across every ``deps`` entry of any +``feature_requirements``/``component_requirements``/ +``assumed_system_requirements``/``assumptions_of_use`` target (so a target +that lists both an AoU's owner and a forwarder of that same AoU directly in +its own ``deps`` still resolves cleanly). It only ever considers +``ScoreReq.AoU`` and ``ScoreReq.ReceivedAoU`` records -- any other record +type colliding on the same identity is a genuine authoring error and is left +untouched so TRLC's own duplicate-definition check still catches it. + +**A raw ``ScoreReq.AoU`` record is never re-exposed verbatim through any +``dependable_element``'s own ``TrlcProviderInfo``.** A target can always +resolve a ``derived_from`` reference to an AoU by depending directly on the +``assumptions_of_use`` target that authored it (the normal, fully-linked +consumption path, unaffected by any of this). What's retyped is only the +copy re-exposed *through a dependable_element's own aggregate +TrlcProviderInfo* -- whether it is the element's own directly-authored AoU +(first-hop exposure, see ``expose_own_aou_trlc.py``) or one it received and +is chain-forwarding further (see ``filter_forwarded_trlc.py``); either is +always retyped to ``ScoreReq.ReceivedAoU`` before being exposed that way. +Since a given ``package.name`` identity can only ever be legitimately +authored once (by its true owner, as ``ScoreReq.AoU``), any duplicate +declaration reachable through one or more dependable_elements' +``TrlcProviderInfo`` is therefore always the same original reached via a +different path -- never two independently-authored, unrelated AoUs that +coincidentally share a name (that general TRLC authoring risk exists for +every record type, not something introduced by AoU forwarding, and is +unaffected by this tool). For each ``Package.RecordName`` identity declared +more than once across the full set of input files, exactly one declaration +is kept and the rest are dropped (everything else in those files -- headers, +unrelated records -- is left untouched); an original ``ScoreReq.AoU`` is +preferred if one happens to be present (defensive only -- it should never +actually occur once ``expose_own_aou_trlc.py`` is wired in everywhere it +needs to be), otherwise the declaration from the lexicographically-first +input path is kept, purely for build-to-build determinism -- which specific +copy "wins" carries no semantic weight, since the identity, type, and +safety classification are the same either way and only the free-text +``justification`` may differ +between candidates. + +This is a lightweight, regex/brace-matching based tool -- like +``rst_to_trlc.py``/``filter_forwarded_trlc.py`` -- not a full TRLC semantic +parser. +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +from trlc_record_utils import extract_records, parse_package + +_LEVEL_MAP = { + "error": logging.ERROR, + "warn": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} + +logger = logging.getLogger(__name__) + +_DEDUPE_TYPES = frozenset({"ScoreReq.AoU", "ScoreReq.ReceivedAoU"}) + + +def dedupe_trlc_sources( + sources: list[tuple[str, str]], +) -> tuple[list[str], dict[str, str]]: + """Drop duplicate AoU/ReceivedAoU declarations across a set of .trlc files. + + Args: + sources: List of ``(path, source_text)`` tuples, one per input file, + in the order the caller wants ties broken by if no ``AoU`` + (non-forwarded) candidate is present for a given identity. + + Returns: + A ``(filtered_sources, dropped_by_base_id)`` tuple: + ``filtered_sources`` is order-aligned with ``sources``, each entry + being that file's text with any losing duplicate record spans + removed (files with no duplicates are returned byte-identical). + ``dropped_by_base_id`` maps each ``Package.RecordName`` identity + that had a duplicate to the path of the file whose declaration was + kept (for logging/debugging). + """ + parsed = [(path, parse_package(text, path), extract_records(text)) for path, text in sources] + + # base_id -> list of (file_index, record_index, record_type, path) + occurrences: dict[str, list[tuple[int, int, str, str]]] = {} + for file_index, (path, package, records) in enumerate(parsed): + for record_index, (record_type, name, _text, _start, _end) in enumerate(records): + if record_type not in _DEDUPE_TYPES: + continue + occurrences.setdefault(f"{package}.{name}", []).append((file_index, record_index, record_type, path)) + + losers: set[tuple[int, int]] = set() + kept_path_by_base_id: dict[str, str] = {} + for base_id, occs in occurrences.items(): + if len(occs) <= 1: + continue + # Prefer an original (non-forwarded) AoU declaration -- defensive + # only, see module docstring: this should never actually occur in + # practice since a raw AoU record is never re-exposed verbatim + # through any dependable_element's own TrlcProviderInfo. Otherwise + # fall back to the lexicographically-first input path for + # determinism. + winner = min(occs, key=lambda o: (0 if o[2] == "ScoreReq.AoU" else 1, o[3])) + kept_path_by_base_id[base_id] = winner[3] + for occ in occs: + if occ != winner: + losers.add((occ[0], occ[1])) + logger.info( + "Duplicate AoU identity %s declared in %d files; keeping %s (%s)", + base_id, + len(occs), + winner[3], + winner[2], + ) + + filtered_sources: list[str] = [] + for file_index, (path, _package, records) in enumerate(parsed): + _original_path, text = sources[file_index] + spans_to_remove = sorted( + (records[record_index][3], records[record_index][4]) + for record_index in range(len(records)) + if (file_index, record_index) in losers + ) + if not spans_to_remove: + filtered_sources.append(text) + continue + new_text = text + for start, end in reversed(spans_to_remove): + new_text = new_text[:start] + new_text[end:] + filtered_sources.append(new_text) + + return filtered_sources, kept_path_by_base_id + + +def main() -> None: + """Entry point for the AoU/ReceivedAoU TRLC deduplication tool.""" + parser = argparse.ArgumentParser( + description="Deduplicate AoU/ReceivedAoU TRLC records reachable via more than one path.", + ) + parser.add_argument( + "--inputs", + nargs="+", + required=True, + help="Input .trlc files (order-aligned with --outputs).", + ) + parser.add_argument( + "--outputs", + nargs="+", + required=True, + help="Output .trlc file paths, one per --inputs entry, same order.", + ) + parser.add_argument( + "--log-level", + choices=["error", "warn", "info", "debug"], + default="warn", + dest="log_level", + help="Log level for tool output (default: warn).", + ) + + args = parser.parse_args() + logging.basicConfig(level=_LEVEL_MAP[args.log_level], format="%(levelname)s: %(message)s") + + if len(args.inputs) != len(args.outputs): + raise SystemExit( + f"--inputs has {len(args.inputs)} entries but --outputs has {len(args.outputs)}; " + "they must be order-aligned and the same length." + ) + + sources = [(path, Path(path).read_text(encoding="utf-8")) for path in args.inputs] + filtered_sources, kept_path_by_base_id = dedupe_trlc_sources(sources) + + for output_path, filtered in zip(args.outputs, filtered_sources): + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(filtered, encoding="utf-8") + + logger.info( + "Resolved %d duplicate AoU identities across %d input files", len(kept_path_by_base_id), len(args.inputs) + ) + + +if __name__ == "__main__": + main() diff --git a/bazel/rules/rules_score/src/expose_own_aou_trlc.py b/bazel/rules/rules_score/src/expose_own_aou_trlc.py new file mode 100644 index 00000000..82280490 --- /dev/null +++ b/bazel/rules/rules_score/src/expose_own_aou_trlc.py @@ -0,0 +1,149 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Retype a dependable_element's own AoU TRLC records for first-hop exposure. + +Companion to ``filter_forwarded_trlc.py``. A dependable_element's own +``assumptions_of_use`` targets author their AoUs as plain ``ScoreReq.AoU`` +records. A downstream target can always resolve a ``derived_from`` +reference to one of these by depending directly on that +``assumptions_of_use`` target -- that is the normal, fully-linked way to +consume an AoU, and this tool has no effect on it. What must never happen +is that raw ``AoU`` record being re-exposed, verbatim, through the +dependable_element's own aggregate ``TrlcProviderInfo`` -- used by anything +that depends on the dependable_element label instead, precisely so it does +not need to know the AoU's true owner. Doing so verbatim would let a +downstream target's TRLC compilation exercise the exact same "dangling +record with no linkage in this scope" problem that +``filter_forwarded_trlc.py`` was written to avoid for chain-forwarded +records. + +So every AoU a dependable_element re-exposes through its own +``TrlcProviderInfo`` -- whether it is one of its own (first-hop exposure, +this tool) or one it received and is chain-forwarding further +(``filter_forwarded_trlc.py``) -- is retyped to ``ScoreReq.ReceivedAoU``. +Internally, within the ``assumptions_of_use`` target's own compilation (and +for any consumer depending on it directly), the record stays +``ScoreReq.AoU``; only the copy re-exposed via the owning +dependable_element's ``TrlcProviderInfo`` is rewritten. + +Unlike ``filter_forwarded_trlc.py``, there is no YAML-based selection here: +a dependable_element's own AoUs are unconditionally exposed in full through +its own ``TrlcProviderInfo`` (only chain-forwarding -- re-exposing AoUs +*received from* a dependency -- is gated by ``aou_forwarding.yaml``), so +every record in every input file is retyped and kept, using a fixed, +generic ``justification`` (there is no per-AoU forwarding-YAML entry to +source per-record justification text from, since this isn't a forwarding +decision -- it's the element making its own AoU visible for downstream +cross-referencing). + +This is a lightweight, regex/brace-matching based tool -- like +``rst_to_trlc.py``/``filter_forwarded_trlc.py`` -- not a full TRLC semantic +parser. +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +from trlc_record_utils import extract_records, retype_as_received_aou + +_LEVEL_MAP = { + "error": logging.ERROR, + "warn": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} + +logger = logging.getLogger(__name__) + +DEFAULT_JUSTIFICATION = "Directly owned by this dependable_element; exposed for downstream requirement traceability." + + +def expose_own_aou_source(source: str, justification: str = DEFAULT_JUSTIFICATION) -> str: + """Retype every top-level AoU record in a .trlc source string. + + Args: + source: Full text of the owning ``assumptions_of_use`` target's + .trlc file. + justification: The ``justification`` field text to embed in every + retyped record. + + Returns: + The rewritten .trlc source: original header preserved verbatim, + every record retyped to ``ScoreReq.ReceivedAoU`` with + ``justification`` injected, in original order. + """ + records = extract_records(source) + header_end = records[0][3] if records else len(source) + header = source[:header_end] + + kept = [retype_as_received_aou(text, justification) for _record_type, _name, text, _start, _end in records] + + body = "\n\n".join(kept) + if body: + return header.rstrip("\n") + "\n\n" + body + "\n" + return header + + +def main() -> None: + """Entry point for the own-AoU first-hop exposure retyping tool.""" + parser = argparse.ArgumentParser( + description="Retype a dependable_element's own AoU TRLC records for first-hop external exposure.", + ) + parser.add_argument( + "--inputs", + nargs="+", + required=True, + help="Owning assumptions_of_use .trlc files (order-aligned with --outputs).", + ) + parser.add_argument( + "--outputs", + nargs="+", + required=True, + help="Output .trlc file paths, one per --inputs entry, same order.", + ) + parser.add_argument( + "--justification", + default=DEFAULT_JUSTIFICATION, + help="Justification text to embed in every retyped record (default: a generic own-AoU exposure notice).", + ) + parser.add_argument( + "--log-level", + choices=["error", "warn", "info", "debug"], + default="warn", + dest="log_level", + help="Log level for tool output (default: warn).", + ) + + args = parser.parse_args() + logging.basicConfig(level=_LEVEL_MAP[args.log_level], format="%(levelname)s: %(message)s") + + if len(args.inputs) != len(args.outputs): + raise SystemExit( + f"--inputs has {len(args.inputs)} entries but --outputs has {len(args.outputs)}; " + "they must be order-aligned and the same length." + ) + + for input_path, output_path in zip(args.inputs, args.outputs): + source = Path(input_path).read_text(encoding="utf-8") + exposed = expose_own_aou_source(source, args.justification) + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(exposed, encoding="utf-8") + logger.info("Wrote own-AoU exposure TRLC %s -> %s", input_path, output_path) + + +if __name__ == "__main__": + main() diff --git a/bazel/rules/rules_score/src/filter_forwarded_trlc.py b/bazel/rules/rules_score/src/filter_forwarded_trlc.py new file mode 100644 index 00000000..2642120a --- /dev/null +++ b/bazel/rules/rules_score/src/filter_forwarded_trlc.py @@ -0,0 +1,213 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Filter received AoU TRLC source records for chain-forwarding. + +Companion to ``aou_forwarding_to_lobster.py``, operating on the raw ``.trlc`` +requirement *source* files instead of already-extracted lobster JSON. This is +what lets a dependable_element expose the TRLC records of the AoUs it +chain-forwards as part of its own ``TrlcProviderInfo``, so that a downstream +``component_requirements``/``feature_requirements``/... target can list the +dependable_element in its own ``deps`` and resolve a +``derived_from = [Pkg.SomeAoU@1]`` cross-reference against it. + +Reads a chain-forwarding YAML file (the same ``aou_forwarding.yaml`` format +used by ``aou_forwarding_to_lobster.py``) and, for each ``(input, output)`` +``.trlc`` file pair, writes a filtered copy of the input file: the original +``package``/``import`` header is preserved verbatim (so the output remains +syntactically valid TRLC even if zero records match), but only the top-level +record bodies whose id matches an entry in the forwarding YAML are copied +across -- retyped from ``ScoreReq.AoU`` (or, for a multi-hop chain, an +already-``ScoreReq.ReceivedAoU``) to ``ScoreReq.ReceivedAoU``, with a +``justification`` field injected (carried over from the YAML entry). Every +other record body in the file is dropped. Retyping (rather than copying the +original ``AoU`` record verbatim) is deliberate: a raw duplicate ``AoU`` +would be indistinguishable from a second, independently authored assumption +that itself needs full control-measure/safety-analysis linkage, whereas +``ReceivedAoU`` is recognizably a pass-through forwarding placeholder that +shares its identity (package + record name) with the original so +``derived_from`` references keep working unchanged across the whole +forwarding chain. + +Every entry in the forwarding YAML must match at least one record across all +input files; an entry that matches nothing (typo, or an AoU this element +never actually received) is a hard error -- mirroring the same validation +``aou_forwarding_to_lobster.py`` already performs against received lobster +items, so a misconfigured ``aou_forwarding.yaml`` fails loudly rather than +silently under-forwarding at the TRLC level. + +This is a lightweight, regex/brace-matching based tool -- like +``rst_to_trlc.py`` -- not a full TRLC semantic parser. It only needs to +recognize top-level `` { ... }`` record blocks, since +that is the only shape ``score_requirements_rule``-generated (and +hand-authored, following the same convention) TRLC requirement files use. +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +from aou_forwarding_to_lobster import parse_forwarding_yaml +from trlc_record_utils import base_id as _base_id +from trlc_record_utils import extract_records, parse_package +from trlc_record_utils import retype_as_received_aou as _retype_as_received_aou + +_LEVEL_MAP = { + "error": logging.ERROR, + "warn": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} + +logger = logging.getLogger(__name__) + + +def filter_trlc_source( + source: str, + forwarding_entries: list[dict[str, str]], + path: str = "", +) -> tuple[str, set[str]]: + """Filter a .trlc source string down to only the forwarded records. + + Args: + source: Full text of the received .trlc file. + forwarding_entries: Parsed YAML entries with 'aou_id' and + 'justification' fields (see + ``aou_forwarding_to_lobster.parse_forwarding_yaml``). TRLC record + identity has no ``@version`` component (unlike the lobster tag), + so any ``@version`` suffix on an entry's ``aou_id`` is ignored + here -- matching is by ``Package.RecordName`` alone. + path: Path to the file (for error messages only). + + Returns: + A ``(filtered_source, matched_base_ids)`` tuple: the filtered .trlc + source (original header unchanged, followed by only the matched + records -- retyped to ``ReceivedAoU`` with their ``justification`` + field injected, in their original order), and the set of + ``Package.RecordName`` base ids that were actually matched in this + file (for the caller to accumulate across all input files and + validate every YAML entry was matched at least once). + """ + package = parse_package(source, path) + records = extract_records(source) + + justification_by_base_id = {_base_id(e["aou_id"]): e["justification"] for e in forwarding_entries} + + header_end = records[0][3] if records else len(source) + header = source[:header_end] + + kept: list[str] = [] + matched_base_ids: set[str] = set() + for record_type, name, text, _, _ in records: + base_id = f"{package}.{name}" + if base_id in justification_by_base_id: + kept.append(_retype_as_received_aou(text, justification_by_base_id[base_id])) + matched_base_ids.add(base_id) + + body = "\n\n".join(kept) + if body: + filtered = header.rstrip("\n") + "\n\n" + body + "\n" + else: + filtered = header + return filtered, matched_base_ids + + +def check_all_entries_matched(wanted_base_ids: set[str], all_matched_base_ids: set[str]) -> None: + """Fail loudly if any forwarding YAML entry matched no record. + + Mirrors ``aou_forwarding_to_lobster.py``'s ``_match_forwarded_entries`` + behavior: a forwarding YAML entry that never matched any received AoU + TRLC record (typo, or an AoU this element never actually received) is a + configuration error, not something to silently ignore. + + Args: + wanted_base_ids: All ``Package.RecordName`` base ids listed in the + forwarding YAML. + all_matched_base_ids: All base ids actually matched across every + processed input file. + + Raises: + SystemExit: If any entry in ``wanted_base_ids`` was never matched. + """ + unmatched = sorted(wanted_base_ids - all_matched_base_ids) + if not unmatched: + return + available = ", ".join(sorted(all_matched_base_ids)) if all_matched_base_ids else "(none)" + raise SystemExit( + "aou_forwarding.yaml entr%s not found in received AoU TRLC sources: %s. Available IDs: %s" + % ("y" if len(unmatched) == 1 else "ies", ", ".join(unmatched), available) + ) + + +def main() -> None: + """Entry point for the TRLC AoU chain-forwarding filter tool.""" + parser = argparse.ArgumentParser(description="Filter received AoU TRLC source records for chain-forwarding.") + parser.add_argument( + "--yaml", + required=True, + help="Path to the aou_forwarding.yaml file listing AoU IDs to further-forward.", + ) + parser.add_argument( + "--inputs", + nargs="+", + required=True, + help="Received .trlc files (order-aligned with --outputs).", + ) + parser.add_argument( + "--outputs", + nargs="+", + required=True, + help="Output .trlc file paths, one per --inputs entry, same order.", + ) + parser.add_argument( + "--log-level", + choices=["error", "warn", "info", "debug"], + default="warn", + dest="log_level", + help="Log level for tool output (default: warn).", + ) + + args = parser.parse_args() + logging.basicConfig(level=_LEVEL_MAP[args.log_level], format="%(levelname)s: %(message)s") + + if len(args.inputs) != len(args.outputs): + raise SystemExit( + f"--inputs has {len(args.inputs)} entries but --outputs has {len(args.outputs)}; " + "they must be order-aligned and the same length." + ) + + forwarding_entries = parse_forwarding_yaml(args.yaml) + + all_matched_base_ids: set[str] = set() + for input_path, output_path in zip(args.inputs, args.outputs): + source = Path(input_path).read_text(encoding="utf-8") + filtered, matched_base_ids = filter_trlc_source(source, forwarding_entries, input_path) + all_matched_base_ids |= matched_base_ids + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(filtered, encoding="utf-8") + logger.info("Wrote filtered TRLC %s -> %s", input_path, output_path) + + wanted_base_ids = {_base_id(e["aou_id"]) for e in forwarding_entries} + check_all_entries_matched(wanted_base_ids, all_matched_base_ids) + + logger.info( + "Matched %d/%d forwarding entries to received AoU TRLC records", + len(wanted_base_ids & all_matched_base_ids), + len(wanted_base_ids), + ) + + +if __name__ == "__main__": + main() diff --git a/bazel/rules/rules_score/src/trlc_record_utils.py b/bazel/rules/rules_score/src/trlc_record_utils.py new file mode 100644 index 00000000..2a046aed --- /dev/null +++ b/bazel/rules/rules_score/src/trlc_record_utils.py @@ -0,0 +1,159 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Lightweight, regex/brace-matching helpers for reading ``.trlc`` source text. + +Shared by ``filter_forwarded_trlc.py`` (selects+retypes chain-forwarded AoU +records), ``expose_own_aou_trlc.py`` (retypes a dependable_element's own +directly-authored AoU records for first-hop external exposure), and +``dedupe_aou_trlc.py`` (drops duplicate AoU/ReceivedAoU identities that +reach the same TRLC check via more than one path). None of these tools is a +full TRLC semantic parser -- they only need to recognize top-level +`` { ... }`` record blocks, since that is the only shape +``score_requirements_rule``-generated (and hand-authored, following the same +convention) TRLC requirement files use. +""" + +from __future__ import annotations + +import re + +_RE_PACKAGE = re.compile(r"^package\s+(\S+)\s*$", re.MULTILINE) +# Matches the start of a top-level record: " {" at the +# beginning of a line (column 0), mirroring the output shape of +# rst_to_trlc.py's render_trlc() and every hand-authored .trlc fixture in +# this repository. +_RE_RECORD_START = re.compile(r"^([\w.]+)\s+([\w]+)\s*\{", re.MULTILINE) + + +def parse_package(source: str, path: str) -> str: + """Extract the ``package NAME`` declaration from a .trlc source string. + + Args: + source: Full text of the .trlc file. + path: Path to the file (for error messages only). + + Returns: + The declared package name. + + Raises: + SystemExit: If no ``package`` statement is found. + """ + m = _RE_PACKAGE.search(source) + if not m: + raise SystemExit(f"TRLC file {path} has no 'package NAME' declaration.") + return m.group(1) + + +def extract_records(source: str) -> list[tuple[str, str, str, int, int]]: + """Find all top-level record blocks in a .trlc source string. + + Args: + source: Full text of the .trlc file. + + Returns: + List of (record_type, record_name, record_text, start_offset, + end_offset) tuples, in file order. ``record_type`` is the fully + qualified type token (e.g. ``ScoreReq.AoU``). ``record_text`` spans + from the start of the record's first line + (`` {``) through its matching closing brace, + inclusive. + + Raises: + SystemExit: If a record's braces are unbalanced. + """ + records: list[tuple[str, str, str, int, int]] = [] + for m in _RE_RECORD_START.finditer(source): + record_type = m.group(1) + name = m.group(2) + start = m.start() + # Find the matching closing brace by counting braces from the + # record's opening brace onward. Requirement record bodies in this + # codebase are flat attribute lists (no nested braces), but brace + # counting keeps this correct even if a value happens to contain one. + depth = 0 + end = None + for i in range(m.end() - 1, len(source)): + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break + if end is None: + raise SystemExit(f"Unterminated record '{name}' (unbalanced braces).") + records.append((record_type, name, source[start:end], start, end)) + return records + + +def base_id(aou_id: str) -> str: + """Strip an optional '@version' suffix from a forwarding YAML aou_id.""" + return aou_id.split("@", 1)[0] + + +RECEIVED_AOU_TYPE = "ScoreReq.ReceivedAoU" +# Matches an existing "justification = "...""" field line (with escaped +# quotes/backslashes inside the string handled), so a record that is already +# ReceivedAoU (an own-AoU first-hop exposure, or an earlier forwarding hop) +# has its previous justification removed before a new one is injected -- +# otherwise TRLC rejects the record for assigning the same component twice. +_RE_JUSTIFICATION_FIELD = re.compile(r'^[ \t]*justification\s*=\s*"(?:[^"\\]|\\.)*"[ \t]*\n?', re.MULTILINE) + + +def escape_trlc_string(text: str) -> str: + """Escape a string for use inside a TRLC double-quoted literal.""" + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def retype_as_received_aou(record_text: str, justification: str) -> str: + """Rewrite a record's type to ``ScoreReq.ReceivedAoU`` and inject ``justification``. + + Used both when chain-forwarding an already-received AoU/ReceivedAoU + (``filter_forwarded_trlc.py``) and when first exposing a + dependable_element's own directly-authored ``AoU`` records through the + dependable_element's own ``TrlcProviderInfo`` + (``expose_own_aou_trlc.py``) -- in both cases the *retyped* record is + only what a `dependable_element` re-exposes to a consumer that depends + on its label instead of the true owner. A target that depends directly + on the `assumptions_of_use` target that authored the AoU still resolves + against the true, unmodified ``AoU`` record -- that consumption path is + unaffected by this retyping and remains the normal, fully-linked way to + reference an AoU. The retyped placeholder shares its identity (package + + record name) with the original so ``derived_from`` references keep + working unchanged regardless of which of the two paths resolved them. + + If the record already carries a ``justification`` field (it is already + ``ReceivedAoU`` -- own-AoU first-hop exposure, or an earlier forwarding + hop), that existing field is replaced rather than duplicated: TRLC + rejects a record that assigns the same component twice. + + Args: + record_text: The original record text, starting with + `` {`` (see ``extract_records``). + justification: The forwarding/exposure justification text to embed + as the record's ``justification`` field. + + Returns: + The rewritten record text, same body otherwise. + """ + first_newline = record_text.find("\n") + first_line = record_text[:first_newline] if first_newline != -1 else record_text + rest = record_text[first_newline:] if first_newline != -1 else "" + rest = _RE_JUSTIFICATION_FIELD.sub("", rest, count=1) + + new_first_line = _RE_RECORD_START.sub( + lambda m: f"{RECEIVED_AOU_TYPE} {m.group(2)} {{", + first_line, + count=1, + ) + return f'{new_first_line}\n justification = "{escape_trlc_string(justification)}"{rest}' diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 5df54825..f66053d4 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -284,6 +284,14 @@ architectural_design( static = ["fixtures/test_dependable_element.puml"], ) +# Dedicated architectural_design for ":test_dependable_element_aou_middle": +# its puml package name must match the label, so it can't share +# ":arch_design_elem" (scoped to "test_dependable_element"). +architectural_design( + name = "arch_design_elem_aou_middle", + static = ["fixtures/test_dependable_element_aou_middle.puml"], +) + architectural_design( name = "arch_design_nested", static = ["fixtures/test_dependable_element_nested.puml"], @@ -963,6 +971,82 @@ dependable_element( tests = [], # Empty for testing ) +# Intermediate dependable_element in an AoU chain-forwarding scenario: +# receives AoUs from ":test_dependable_element" (its own AoUs, exposed via +# TrlcProviderInfo) and chain-forwards a single one of them onward (selected +# by fixtures/seooc_test/aou_forwarding_select_temp.yaml) so that a further +# downstream requirements target (":comp_req_chain_aou" below) can reference +# it via `derived_from` without needing to depend on +# ":test_dependable_element" directly. +dependable_element( + name = "test_dependable_element_aou_middle", + testonly = True, + aou_forwarding = "fixtures/seooc_test/aou_forwarding_select_temp.yaml", + architectural_design = [":arch_design_elem_aou_middle"], + # Deliberately NOT ":aous" (same as ":test_dependable_element"'s own AoUs): + # reusing the identical assumptions_of_use target here as well as + # receiving it forwarded from ":test_dependable_element" would make this + # element's own_aou_trlc and chain_forwarded_trlc contain the exact same + # TRLC record identity twice, which TRLC's own duplicate-definition check + # correctly rejects. ":aous_rst" has disjoint record ids, so it exercises + # "this element has its own AoUs *and* forwards different received ones" + # without any accidental self-collision. + assumptions_of_use = [":aous_rst"], + components = [":test_component"], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + maturity = "development", + requirements = [":feat_req"], + tests = [], # Empty for testing + deps = [":test_dependable_element"], +) + +# Regression/feature fixtures: downstream requirements targets referencing +# AoUs exposed via TrlcProviderInfo -- see plan.md "Expose TrlcProviderInfo +# (AoUs) from dependable_element". +# +# Direct case: ":comp_req_direct_aou" depends directly on +# ":test_dependable_element" (an upstream dependable_element) and references +# one of *its own* AoUs via `derived_from`. This target and its +# auto-generated ":comp_req_direct_aou_test" must build successfully. +component_requirements( + name = "comp_req_direct_aou", + testonly = True, + srcs = ["fixtures/seooc_test/component_requirements_direct_aou.trlc"], + deps = [":test_dependable_element"], +) + +# Chain-forwarded case: ":comp_req_chain_aou" depends on +# ":test_dependable_element_aou_middle" (NOT on ":test_dependable_element" +# directly) and references the AoU that was received-then-chain-forwarded by +# the middle element. This must build successfully, proving chain-forwarded +# AoUs (not just an element's own AoUs) are resolvable at the TRLC level by +# further downstream dependees. +component_requirements( + name = "comp_req_chain_aou", + testonly = True, + srcs = ["fixtures/seooc_test/component_requirements_chain_aou.trlc"], + deps = [":test_dependable_element_aou_middle"], +) + +# Diamond-dependency regression test: ":comp_req_diamond_aou" depends on BOTH +# ":test_dependable_element" (the AoU's original owner) AND +# ":test_dependable_element_aou_middle" (which chain-forwards that same AoU, +# retyped to ScoreReq.ReceivedAoU with the identical package + record name -- +# see filter_forwarded_trlc.py). Without deduplication (aou_trlc_dedupe.bzl / +# dedupe_aou_trlc.py), the same AoU identity would be merged twice into one +# TRLC parse and rejected by TRLC's own duplicate-definition check. This must +# build and test successfully, proving the diamond shape is handled. +component_requirements( + name = "comp_req_diamond_aou", + testonly = True, + srcs = ["fixtures/seooc_test/component_requirements_diamond_aou.trlc"], + deps = [ + ":test_dependable_element", + ":test_dependable_element_aou_middle", + ], +) + # Dependable elements wrapping a test_case_coverage_lock component — used to verify the # ComponentTestCaseCoverageLockCheck action wiring (mnemonic/inputs/argv) at both # maturity levels via test_case_coverage_lock_check_action_{release,development}_test. @@ -1775,6 +1859,33 @@ py_test( ], ) +py_test( + name = "test_filter_forwarded_trlc", + size = "small", + srcs = ["test_filter_forwarded_trlc.py"], + deps = [ + "@score_tooling//bazel/rules/rules_score:filter_forwarded_trlc", + ], +) + +py_test( + name = "test_dedupe_aou_trlc", + size = "small", + srcs = ["test_dedupe_aou_trlc.py"], + deps = [ + "@score_tooling//bazel/rules/rules_score:dedupe_aou_trlc", + ], +) + +py_test( + name = "test_expose_own_aou_trlc", + size = "small", + srcs = ["test_expose_own_aou_trlc.py"], + deps = [ + "@score_tooling//bazel/rules/rules_score:expose_own_aou_trlc", + ], +) + py_test( name = "test_sphinx_html_merge", size = "small", @@ -1827,6 +1938,9 @@ test_suite( ":sphinx_module_tests", ":test_aou_forwarding_to_lobster", ":test_bazel_sphinx_needs", + ":test_dedupe_aou_trlc", + ":test_expose_own_aou_trlc", + ":test_filter_forwarded_trlc", ":test_fmea_assembler", ":test_rst_to_trlc", ":test_sphinx_html_merge", @@ -1838,3 +1952,20 @@ test_suite( "//fixtures/spec_extras:requirements_spec_extras_tests", ], ) + +# Negative regression check (manual verification, not a standing green test): +# references an AoU ("supply_voltage") that was received by +# ":test_dependable_element_aou_middle" but NOT selected in +# fixtures/seooc_test/aou_forwarding_select_temp.yaml -- must FAIL to build, +# proving non-selected AoUs are genuinely excluded from chain_forwarded_trlc, +# not merely unfiltered. Kept tagged "manual" (not part of //... or any test +# suite) since this repo has no expect-failure Bazel test harness; verify by +# running `bazel build :comp_req_chain_aou_negative_test` and confirming it +# fails with an "undefined identifier" TRLC error. +component_requirements( + name = "comp_req_chain_aou_negative", + testonly = True, + srcs = ["fixtures/seooc_test/component_requirements_chain_aou_negative.trlc"], + tags = ["manual"], + deps = [":test_dependable_element_aou_middle"], +) diff --git a/bazel/rules/rules_score/test/MODULE.bazel b/bazel/rules/rules_score/test/MODULE.bazel index 7ff05d0e..24e3dc71 100644 --- a/bazel/rules/rules_score/test/MODULE.bazel +++ b/bazel/rules/rules_score/test/MODULE.bazel @@ -87,14 +87,65 @@ register_toolchains( ############################################################################### bazel_dep(name = "toolchains_llvm", version = "1.6.0") +# Hermetic sysroot for the LLVM toolchain, mirroring the root +# eclipse-score/tooling MODULE.bazel's llvm.sysroot() setup (reusing its +# manifest/lockfile via the score_tooling local_path_override above, since +# this test module has none of its own). Repo names are prefixed +# "test_" to stay unique across the bzlmod module-extension graph. +bazel_dep(name = "tar.bzl", version = "0.6.0") +bazel_dep(name = "rules_distroless", version = "0.8.0") + +apt = use_extension("@rules_distroless//apt:extensions.bzl", "apt") +apt.install( + name = "test_tooling_sysroot", + manifest = "@score_tooling//third_party/tooling_sysroot:manifest.yaml", + nolock = True, +) +use_repo(apt, "test_tooling_sysroot", "test_tooling_sysroot_resolve") + +sysroot_from_lock = use_repo_rule( + "@score_tooling//bazel/rules/sysroot_from_lock:sysroot_from_lock.bzl", + "sysroot_from_lock", +) + +sysroot_from_lock( + name = "test_tooling_sysroot_amd64", + architecture = "amd64", + lock = "@test_tooling_sysroot_resolve//:lock.json", +) + llvm = use_extension( "@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm", + dev_dependency = True, ) llvm.toolchain( + compile_flags = {"": [ + "-march=nehalem", + "-ffp-model=strict", + # Security + "-U_FORTIFY_SOURCE", # https://github.com/google/sanitizers/issues/247 + "-fstack-protector", + "-fno-omit-frame-pointer", + # Diagnostics + "-fcolor-diagnostics", + "-Wno-deprecated-declarations", + "-Wno-error=self-assign-overloaded", + "-Wthread-safety", + ]}, cxx_standard = {"": "c++17"}, + extra_link_libs = {"": [ + "-lrt", + # This is the agreed way to ensure linking for targets using std::atomic operations. + "-latomic", + ]}, llvm_version = "19.1.7", ) +llvm.sysroot( + name = "llvm_toolchain", + label = "@test_tooling_sysroot_amd64//sysroot", + targets = ["linux-x86_64"], +) use_repo(llvm, "llvm_toolchain", "llvm_toolchain_llvm") register_toolchains( diff --git a/bazel/rules/rules_score/test/fixtures/seooc_test/aou_forwarding_select_temp.yaml b/bazel/rules/rules_score/test/fixtures/seooc_test/aou_forwarding_select_temp.yaml new file mode 100644 index 00000000..ff2c6055 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/aou_forwarding_select_temp.yaml @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +# +# Test fixture: selects a single AoU received from ":test_dependable_element" +# for chain-forwarding onward by ":test_dependable_element_aou_middle" -- used +# to exercise both the lobster-level and the TRLC-level AoU chain-forwarding +# paths (see filter_forwarded_trlc.py / aou_forwarding_to_lobster.py). +forwarded_aous: + - aou_id: "AssumptionsOfUse.aou_req__seooc_test__operating_temperature_range@1" + justification: "Test fixture: exercises AoU chain-forwarding at both the lobster and TRLC level." diff --git a/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou.trlc b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou.trlc new file mode 100644 index 00000000..760e42c0 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou.trlc @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +package TestComponentChainReceivedAoU + +import ScoreReq +import AssumptionsOfUse + +ScoreReq.CompReq REQ_COMP_CHAIN_AOU_001 { + description = "Downstream requirement referencing an AoU that was received by an intermediate dependable_element and chain-forwarded onward (not this element's own AoU, and not a direct dependency of the AoU's original owner)." + safety = ScoreReq.Asil.B + derived_from = [AssumptionsOfUse.aou_req__seooc_test__operating_temperature_range@1] + version = 1 +} diff --git a/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou_negative.trlc b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou_negative.trlc new file mode 100644 index 00000000..cf7d8995 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou_negative.trlc @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +package TestComponentChainAoUNegative + +import ScoreReq +import AssumptionsOfUse + +ScoreReq.CompReq REQ_COMP_CHAIN_AOU_NEG_001 { + description = "Negative check: references an AoU that was received but NOT selected for chain-forwarding." + safety = ScoreReq.Asil.B + derived_from = [AssumptionsOfUse.aou_req__seooc_test__supply_voltage@1] + version = 1 +} diff --git a/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_diamond_aou.trlc b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_diamond_aou.trlc new file mode 100644 index 00000000..21d19901 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_diamond_aou.trlc @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +package TestComponentDiamondAoU + +import ScoreReq +import AssumptionsOfUse + +ScoreReq.CompReq REQ_COMP_DIAMOND_AOU_001 { + description = "Diamond dependency regression: depends on both the AoU's original owner directly and an intermediate dependable_element that chain-forwards that same AoU. Must resolve without a TRLC duplicate-definition error." + safety = ScoreReq.Asil.B + derived_from = [AssumptionsOfUse.aou_req__seooc_test__operating_temperature_range@1] + version = 1 +} diff --git a/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_direct_aou.trlc b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_direct_aou.trlc new file mode 100644 index 00000000..53b779bb --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_direct_aou.trlc @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +package TestComponentDirectAoU + +import ScoreReq +import AssumptionsOfUse + +ScoreReq.CompReq REQ_COMP_DIRECT_AOU_001 { + description = "Downstream requirement referencing an upstream dependable_element's own AoU directly via derived_from." + safety = ScoreReq.Asil.B + derived_from = [AssumptionsOfUse.aou_req__seooc_test__operating_temperature_range@1] + version = 1 +} diff --git a/bazel/rules/rules_score/test/fixtures/test_dependable_element_aou_middle.puml b/bazel/rules/rules_score/test/fixtures/test_dependable_element_aou_middle.puml new file mode 100644 index 00000000..a33b88f2 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/test_dependable_element_aou_middle.puml @@ -0,0 +1,23 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml test_dependable_element_aou_middle + +package "Test Dependable Element AoU Middle" as test_dependable_element_aou_middle <> { + component "Test Component" as test_component <> { + component "Test Unit" as test_unit <> + component "Test Binary Unit" as test_binary_unit <> + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/test_aou_forwarding_to_lobster.py b/bazel/rules/rules_score/test/test_aou_forwarding_to_lobster.py index 324ad7d2..64188795 100644 --- a/bazel/rules/rules_score/test/test_aou_forwarding_to_lobster.py +++ b/bazel/rules/rules_score/test/test_aou_forwarding_to_lobster.py @@ -14,8 +14,11 @@ import tempfile import unittest +from pathlib import Path import yaml +from lobster.common.errors import Message_Handler +from lobster.common.io import lobster_read, lobster_write from lobster.common.items import Requirement, Tracing_Tag from lobster.common.location import Void_Reference @@ -210,20 +213,41 @@ def test_builds_one_marker_per_entry(self) -> None: self.assertEqual(len(markers), 2) def test_marker_has_distinct_tag_and_refs_original(self) -> None: - """The marker's tag must not collide with the original item's tag - (so it can coexist with the "Received AoUs" level in the same - report), but its refs must point at the original tag.""" + """The marker's tag must not collide with the original item's tag, + but its refs must point at the original tag.""" items = [_req("req Pkg.AoU1@1", "AoU1")] entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] markers = build_forwarded_markers(entries, items, "aou_forwarding.yaml") marker = markers[0] self.assertNotEqual(str(marker.tag), "req Pkg.AoU1@1") - self.assertEqual(str(marker.tag), "req Pkg.AoU1__forwarded") + self.assertEqual(str(marker.tag), "aou_forwarding_marker Pkg.AoU1@1") + self.assertEqual(marker.tag.tag, "Pkg.AoU1") self.assertEqual( [str(ref) for ref in marker.unresolved_references], ["req Pkg.AoU1@1"], ) + def test_marker_tag_survives_json_round_trip_without_colliding(self) -> None: + """Regression test: a marker built from a versioned original tag + must not collapse onto the original item's tag.key() after a + write-then-read-back round trip through actual .lobster JSON.""" + items = [_req("req Pkg.AoU1@1", "AoU1")] + entries = [{"aou_id": "Pkg.AoU1@1", "justification": "reason"}] + markers = build_forwarded_markers(entries, items, "aou_forwarding.yaml") + + with tempfile.TemporaryDirectory() as tmpdir: + marker_path = Path(tmpdir) / "markers.lobster" + with open(marker_path, "w", encoding="utf-8") as f: + lobster_write(f, Requirement, "test", markers) + + mh = Message_Handler() + reloaded: dict[str, Requirement] = {} + lobster_read(mh, str(marker_path), "test-level", reloaded) + + self.assertEqual(len(reloaded), 1) + (reloaded_marker,) = reloaded.values() + self.assertNotEqual(reloaded_marker.tag.key(), items[0].tag.key()) + def test_marker_uses_justification_as_text(self) -> None: items = [_req("req Pkg.AoU1@1", "AoU1")] entries = [{"aou_id": "Pkg.AoU1", "justification": "must be handled downstream"}] diff --git a/bazel/rules/rules_score/test/test_dedupe_aou_trlc.py b/bazel/rules/rules_score/test/test_dedupe_aou_trlc.py new file mode 100644 index 00000000..b30e59aa --- /dev/null +++ b/bazel/rules/rules_score/test/test_dedupe_aou_trlc.py @@ -0,0 +1,128 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for dedupe_aou_trlc.""" + +import unittest + +from dedupe_aou_trlc import dedupe_trlc_sources + +_ORIGINAL = """\ +package MyAoUs + +import ScoreReq + +ScoreReq.AoU AoU1 { + description = "First AoU." + safety = ScoreReq.Asil.B + version = 1 +} + +ScoreReq.AoU AoU2 { + description = "Second AoU." + safety = ScoreReq.Asil.B + version = 1 +} +""" + +_FORWARDED_COPY_OF_AOU1 = """\ +package MyAoUs + +import ScoreReq + +ScoreReq.ReceivedAoU AoU1 { + justification = "forwarded because reasons" + description = "First AoU." + safety = ScoreReq.Asil.B + version = 1 +} +""" + +_UNRELATED = """\ +package Other + +import ScoreReq + +ScoreReq.CompReq SomeReq { + description = "Not an AoU at all." + safety = ScoreReq.Asil.B + derived_from = [MyAoUs.AoU2@1] + version = 1 +} +""" + + +class TestDedupeTrlcSources(unittest.TestCase): + """Tests for dedupe_trlc_sources.""" + + def test_no_duplicates_returns_sources_unchanged(self) -> None: + sources = [("a.trlc", _ORIGINAL), ("b.trlc", _UNRELATED)] + filtered, dropped = dedupe_trlc_sources(sources) + self.assertEqual(filtered, [_ORIGINAL, _UNRELATED]) + self.assertEqual(dropped, {}) + + def test_original_aou_wins_over_forwarded_copy(self) -> None: + sources = [("a.trlc", _ORIGINAL), ("b.trlc", _FORWARDED_COPY_OF_AOU1)] + filtered, dropped = dedupe_trlc_sources(sources) + # a.trlc (the original) is untouched. + self.assertEqual(filtered[0], _ORIGINAL) + # b.trlc loses its ReceivedAoU copy of AoU1 but keeps its header. + self.assertNotIn("AoU1", filtered[1]) + self.assertIn("package MyAoUs", filtered[1]) + self.assertEqual(dropped, {"MyAoUs.AoU1": "a.trlc"}) + + def test_original_wins_regardless_of_input_order(self) -> None: + sources = [("b.trlc", _FORWARDED_COPY_OF_AOU1), ("a.trlc", _ORIGINAL)] + filtered, dropped = dedupe_trlc_sources(sources) + self.assertNotIn("AoU1", filtered[0]) + self.assertEqual(filtered[1], _ORIGINAL) + self.assertEqual(dropped, {"MyAoUs.AoU1": "a.trlc"}) + + def test_two_forwarded_copies_pick_lexicographically_first_path(self) -> None: + copy_a = _FORWARDED_COPY_OF_AOU1 + copy_b = _FORWARDED_COPY_OF_AOU1.replace("forwarded because reasons", "different reason") + sources = [("z_hop.trlc", copy_a), ("a_hop.trlc", copy_b)] + filtered, dropped = dedupe_trlc_sources(sources) + self.assertNotIn("AoU1", filtered[0]) + self.assertIn("AoU1", filtered[1]) + self.assertEqual(dropped, {"MyAoUs.AoU1": "a_hop.trlc"}) + + def test_non_aou_records_are_never_touched(self) -> None: + """A colliding non-AoU/ReceivedAoU record type must be left alone -- + that is a genuine authoring error TRLC's own check should still + catch, not something this tool silently resolves.""" + duplicate_comp_req = _UNRELATED + sources = [("a.trlc", _UNRELATED), ("b.trlc", duplicate_comp_req)] + filtered, dropped = dedupe_trlc_sources(sources) + self.assertEqual(filtered, [_UNRELATED, duplicate_comp_req]) + self.assertEqual(dropped, {}) + + def test_unrelated_records_in_a_deduped_file_are_preserved(self) -> None: + mixed = ( + _FORWARDED_COPY_OF_AOU1 + + "\n" + + _UNRELATED.replace("package Other", "package MyAoUs").replace("MyAoUs.AoU2@1", "AoU2@1") + ) + sources = [("a.trlc", _ORIGINAL), ("b.trlc", mixed)] + filtered, _dropped = dedupe_trlc_sources(sources) + self.assertNotIn("ReceivedAoU AoU1", filtered[1]) + self.assertIn("SomeReq", filtered[1]) + + def test_only_actual_duplicates_are_reported(self) -> None: + sources = [("a.trlc", _ORIGINAL), ("b.trlc", _FORWARDED_COPY_OF_AOU1)] + _filtered, dropped = dedupe_trlc_sources(sources) + self.assertEqual(list(dropped.keys()), ["MyAoUs.AoU1"]) + self.assertNotIn("MyAoUs.AoU2", dropped) + + +if __name__ == "__main__": + unittest.main() diff --git a/bazel/rules/rules_score/test/test_expose_own_aou_trlc.py b/bazel/rules/rules_score/test/test_expose_own_aou_trlc.py new file mode 100644 index 00000000..9c725306 --- /dev/null +++ b/bazel/rules/rules_score/test/test_expose_own_aou_trlc.py @@ -0,0 +1,96 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for expose_own_aou_trlc.""" + +import unittest + +from expose_own_aou_trlc import DEFAULT_JUSTIFICATION, expose_own_aou_source + +_SAMPLE = """\ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + ********************************************************************************/ +package MyAoUs + +import ScoreReq + +ScoreReq.AoU AoU1 { + description = "First AoU." + safety = ScoreReq.Asil.B + version = 1 +} + +ScoreReq.AoU AoU2 { + description = "Second AoU." + safety = ScoreReq.Asil.B + version = 1 +} +""" + +_HEADER_ONLY = "package Empty\n\nimport ScoreReq\n" + + +class TestExposeOwnAouSource(unittest.TestCase): + """Tests for expose_own_aou_source.""" + + def test_all_records_retyped_to_forwarded_aou(self) -> None: + result = expose_own_aou_source(_SAMPLE) + self.assertIn("ScoreReq.ReceivedAoU AoU1 {", result) + self.assertIn("ScoreReq.ReceivedAoU AoU2 {", result) + self.assertNotIn("ScoreReq.AoU AoU1", result) + self.assertNotIn("ScoreReq.AoU AoU2", result) + + def test_default_justification_injected(self) -> None: + result = expose_own_aou_source(_SAMPLE) + self.assertEqual(result.count(f'justification = "{DEFAULT_JUSTIFICATION}"'), 2) + + def test_custom_justification_injected(self) -> None: + result = expose_own_aou_source(_SAMPLE, justification="custom text") + self.assertIn('justification = "custom text"', result) + self.assertNotIn(DEFAULT_JUSTIFICATION, result) + + def test_original_fields_preserved(self) -> None: + result = expose_own_aou_source(_SAMPLE) + self.assertIn('description = "First AoU."', result) + self.assertIn('description = "Second AoU."', result) + self.assertIn("safety = ScoreReq.Asil.B", result) + self.assertIn("version = 1", result) + + def test_record_order_preserved(self) -> None: + result = expose_own_aou_source(_SAMPLE) + self.assertLess(result.index("AoU1"), result.index("AoU2")) + + def test_header_preserved_verbatim(self) -> None: + result = expose_own_aou_source(_SAMPLE) + self.assertIn("package MyAoUs", result) + self.assertIn("import ScoreReq", result) + + def test_header_only_file_with_no_records(self) -> None: + result = expose_own_aou_source(_HEADER_ONLY) + self.assertEqual(result, _HEADER_ONLY) + + def test_identity_unchanged_across_retype(self) -> None: + """Package + record name must be unchanged so a derived_from + reference written against the original AoU keeps resolving.""" + result = expose_own_aou_source(_SAMPLE) + self.assertIn("package MyAoUs", result) + self.assertIn("AoU1 {", result) + self.assertIn("AoU2 {", result) + + def test_justification_is_escaped(self) -> None: + result = expose_own_aou_source(_SAMPLE, justification='contains "quotes" and \\backslash') + self.assertIn('justification = "contains \\"quotes\\" and \\\\backslash"', result) + + +if __name__ == "__main__": + unittest.main() diff --git a/bazel/rules/rules_score/test/test_filter_forwarded_trlc.py b/bazel/rules/rules_score/test/test_filter_forwarded_trlc.py new file mode 100644 index 00000000..181065c4 --- /dev/null +++ b/bazel/rules/rules_score/test/test_filter_forwarded_trlc.py @@ -0,0 +1,231 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for filter_forwarded_trlc.""" + +import unittest + +from filter_forwarded_trlc import check_all_entries_matched, extract_records, filter_trlc_source, parse_package + +_SAMPLE = """\ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + ********************************************************************************/ +package MyAoUs + +import ScoreReq + +ScoreReq.AoU AoU1 { + description = "First AoU." + safety = ScoreReq.Asil.B + version = 1 +} + +ScoreReq.AoU AoU2 { + description = "Second AoU." + safety = ScoreReq.Asil.B + version = 1 +} + +ScoreReq.AoU AoU3 { + description = "Third AoU." + safety = ScoreReq.Asil.B + version = 1 +} +""" + +_NO_PACKAGE = """\ +import ScoreReq + +ScoreReq.AoU AoU1 { + description = "First AoU." +} +""" + + +class TestParsePackage(unittest.TestCase): + """Tests for parse_package.""" + + def test_extracts_package_name(self) -> None: + self.assertEqual(parse_package(_SAMPLE, "sample.trlc"), "MyAoUs") + + def test_missing_package_raises(self) -> None: + with self.assertRaises(SystemExit): + parse_package(_NO_PACKAGE, "no_package.trlc") + + +class TestExtractRecords(unittest.TestCase): + """Tests for extract_records.""" + + def test_extracts_all_records_in_order(self) -> None: + records = extract_records(_SAMPLE) + names = [name for _, name, _, _, _ in records] + self.assertEqual(names, ["AoU1", "AoU2", "AoU3"]) + + def test_record_text_spans_full_block(self) -> None: + records = extract_records(_SAMPLE) + _, _, text, _, _ = records[0] + self.assertTrue(text.startswith("ScoreReq.AoU AoU1 {")) + self.assertTrue(text.rstrip().endswith("}")) + self.assertIn('description = "First AoU."', text) + self.assertNotIn("AoU2", text) + + def test_no_records_returns_empty_list(self) -> None: + header_only = "package P\n\nimport ScoreReq\n" + self.assertEqual(extract_records(header_only), []) + + def test_unterminated_record_raises(self) -> None: + broken = 'package P\n\nScoreReq.AoU AoU1 {\n description = "x"\n' + with self.assertRaises(SystemExit): + extract_records(broken) + + +class TestFilterTrlcSource(unittest.TestCase): + """Tests for filter_trlc_source.""" + + def test_keeps_only_matched_records(self) -> None: + entries = [{"aou_id": "MyAoUs.AoU2", "justification": "reason"}] + result, matched = filter_trlc_source(_SAMPLE, entries) + self.assertIn("AoU2", result) + self.assertNotIn("AoU1", result) + self.assertNotIn("AoU3", result) + self.assertEqual(matched, {"MyAoUs.AoU2"}) + + def test_matched_records_are_retyped_to_forwarded_aou(self) -> None: + entries = [{"aou_id": "MyAoUs.AoU2", "justification": "reason"}] + result, _ = filter_trlc_source(_SAMPLE, entries) + self.assertIn("ScoreReq.ReceivedAoU AoU2 {", result) + self.assertNotIn("ScoreReq.AoU AoU2", result) + + def test_matched_records_get_justification_injected(self) -> None: + entries = [{"aou_id": "MyAoUs.AoU2", "justification": "because reasons"}] + result, _ = filter_trlc_source(_SAMPLE, entries) + self.assertIn('justification = "because reasons"', result) + + def test_justification_is_escaped(self) -> None: + entries = [{"aou_id": "MyAoUs.AoU1", "justification": 'contains "quotes" and \\backslash'}] + result, _ = filter_trlc_source(_SAMPLE, entries) + self.assertIn('justification = "contains \\"quotes\\" and \\\\backslash"', result) + + def test_original_fields_preserved_alongside_justification(self) -> None: + entries = [{"aou_id": "MyAoUs.AoU1", "justification": "reason"}] + result, _ = filter_trlc_source(_SAMPLE, entries) + self.assertIn('description = "First AoU."', result) + self.assertIn("safety = ScoreReq.Asil.B", result) + self.assertIn("version = 1", result) + + def test_multiple_matches_preserve_original_order(self) -> None: + entries = [ + {"aou_id": "MyAoUs.AoU3", "justification": "r1"}, + {"aou_id": "MyAoUs.AoU1", "justification": "r2"}, + ] + result, matched = filter_trlc_source(_SAMPLE, entries) + self.assertLess(result.index("AoU1"), result.index("AoU3")) + self.assertEqual(matched, {"MyAoUs.AoU1", "MyAoUs.AoU3"}) + + def test_header_preserved_even_with_zero_matches(self) -> None: + result, matched = filter_trlc_source(_SAMPLE, []) + self.assertIn("package MyAoUs", result) + self.assertIn("import ScoreReq", result) + self.assertNotIn("AoU1", result) + self.assertNotIn("AoU2", result) + self.assertNotIn("AoU3", result) + self.assertEqual(matched, set()) + + def test_output_is_syntactically_plausible_with_zero_matches(self) -> None: + """Header-only output must still contain the package statement so + a downstream TRLC parse of this (now-empty) file doesn't choke on a + missing package declaration.""" + result, _ = filter_trlc_source(_SAMPLE, []) + self.assertTrue(result.strip().endswith("import ScoreReq")) + + def test_non_matching_ids_in_other_packages_are_ignored(self) -> None: + """An aou_id belonging to a different package must not match here, + since matching is scoped to Package.RecordName.""" + entries = [{"aou_id": "OtherPkg.AoU1", "justification": "reason"}] + result, matched = filter_trlc_source(_SAMPLE, entries) + self.assertNotIn("AoU1 {", result) + self.assertEqual(matched, set()) + + def test_versioned_aou_id_matches_ignoring_version(self) -> None: + """TRLC record identity has no @version; the forwarding YAML's + @version suffix (if any) must be stripped before matching.""" + entries = [{"aou_id": "MyAoUs.AoU1@1", "justification": "reason"}] + result, matched = filter_trlc_source(_SAMPLE, entries) + self.assertIn("AoU1", result) + self.assertEqual(matched, {"MyAoUs.AoU1"}) + + def test_all_records_kept_when_all_selected(self) -> None: + entries = [ + {"aou_id": "MyAoUs.AoU1", "justification": "r1"}, + {"aou_id": "MyAoUs.AoU2", "justification": "r2"}, + {"aou_id": "MyAoUs.AoU3", "justification": "r3"}, + ] + result, matched = filter_trlc_source(_SAMPLE, entries) + for name in ("AoU1", "AoU2", "AoU3"): + self.assertIn(name, result) + self.assertEqual(matched, {"MyAoUs.AoU1", "MyAoUs.AoU2", "MyAoUs.AoU3"}) + + def test_multi_hop_forwarded_aou_is_also_retyped(self) -> None: + """A record that is already ReceivedAoU (a second forwarding hop) + must still be retyped to ReceivedAoU (a no-op type-wise) and get a + fresh justification for this hop.""" + source = ( + "package Mid\n\nimport ScoreReq\n\n" + 'ScoreReq.ReceivedAoU Received1 {\n justification = "hop 1"\n version = 1\n}\n' + ) + entries = [{"aou_id": "Mid.Received1", "justification": "hop 2"}] + result, matched = filter_trlc_source(source, entries) + self.assertIn("ScoreReq.ReceivedAoU Received1 {", result) + self.assertIn('justification = "hop 2"', result) + self.assertEqual(matched, {"Mid.Received1"}) + + def test_multi_hop_forwarding_does_not_duplicate_justification_field(self) -> None: + """Retyping an already-ReceivedAoU record must replace, not + duplicate, the justification field -- a duplicate assignment of the + same component is a TRLC error.""" + source = ( + "package Mid\n\nimport ScoreReq\n\n" + 'ScoreReq.ReceivedAoU Received1 {\n justification = "hop 1"\n version = 1\n}\n' + ) + entries = [{"aou_id": "Mid.Received1", "justification": "hop 2"}] + result, _ = filter_trlc_source(source, entries) + self.assertEqual(result.count("justification ="), 1) + self.assertNotIn("hop 1", result) + + +class TestCheckAllEntriesMatched(unittest.TestCase): + """Tests for check_all_entries_matched.""" + + def test_no_error_when_all_matched(self) -> None: + check_all_entries_matched({"Pkg.A", "Pkg.B"}, {"Pkg.A", "Pkg.B", "Pkg.C"}) + + def test_raises_when_entry_unmatched(self) -> None: + with self.assertRaises(SystemExit): + check_all_entries_matched({"Pkg.A", "Pkg.Typo"}, {"Pkg.A"}) + + def test_error_message_lists_unmatched_and_available(self) -> None: + with self.assertRaises(SystemExit) as ctx: + check_all_entries_matched({"Pkg.Typo"}, {"Pkg.A", "Pkg.B"}) + message = str(ctx.exception) + self.assertIn("Pkg.Typo", message) + self.assertIn("Pkg.A", message) + self.assertIn("Pkg.B", message) + + def test_error_message_handles_no_available_ids(self) -> None: + with self.assertRaises(SystemExit) as ctx: + check_all_entries_matched({"Pkg.Typo"}, set()) + self.assertIn("(none)", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl b/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl index 5d81cc62..7807a21f 100644 --- a/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl +++ b/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl @@ -78,14 +78,19 @@ type ControlMeasure "A design or operational measure that detects, prevents, or type AoU "Assumption of Use — a safety-relevant condition that a user must fulfil." extends ControlMeasure { } +type ReceivedAoU "A re-exposure, identical in identity (package + record name) to the original, of an AoU that a downstream target receives as an input from a dependable_element it depends on -- either the dependable_element's own directly authored AoU (first-hop exposure) or one it itself received from a further dependency and is chain-forwarding onward (or another ReceivedAoU, for a multi-hop chain), because it cannot satisfy the assumption locally. Distinguishing this from a directly declared AoU lets tooling (e.g. Lobster traceability extraction, safety analysis linkage) recognize a pass-through exposure/forwarding placeholder instead of mistaking it for a second, independently authored assumption that itself needs full control-measure linkage." extends ControlMeasure { + justification "Why this AoU is being exposed as an input to dependees this way: a fixed, generic notice for an element's own AoU (first-hop exposure), or -- for a chain-forwarded AoU -- why the forwarding element could not handle the AoU itself, carried over verbatim from the aou_forwarding.yaml entry that authorized the forward." + String +} + tuple CompReqSourceId { - item [FeatReq, AssumedSystemReq, AoU] + item [FeatReq, AssumedSystemReq, AoU, ReceivedAoU] separator @ version Integer } type CompReq "Component-level requirement allocated to a specific software component." extends RequirementSafety { - derived_from "Versioned references to the FeatReq, AssumedSystemReq, or received AoU items this component requirement is derived from. An AoU reference must come from a target listed directly in this target's deps (the dependable_element's own assumptions_of_use or one received/forwarded from its deps). Omit only for component-internal requirements with no feature-level parent." + derived_from "Versioned references to the FeatReq, AssumedSystemReq, or received/forwarded AoU items this component requirement is derived from. An AoU or ReceivedAoU reference must come from a target listed directly in this target's deps (the dependable_element's own assumptions_of_use or one received/chain-forwarded from its deps). Omit only for component-internal requirements with no feature-level parent." CompReqSourceId[1 .. *] }