From e107fd9492e6bc85aadec2f3448a751c3ba460da Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Tue, 15 Sep 2026 15:29:41 +0200 Subject: [PATCH 1/5] Expose AoUs (including chain-forwarded ones) via TrlcProviderInfo dependable_element now exposes both its own and chain-forwarded Assumptions of Use (AoUs) as TRLC records via TrlcProviderInfo, so downstream requirements targets can reference them directly in derived_from without needing visibility into the AoU's original owner. A raw ScoreReq.AoU record must never leave the TRLC compilation of the assumptions_of_use target that authored it: exposing it verbatim to any other target would look, to any tooling walking the requirements model, like a second, independently authored assumption needing its own control-measure/safety-analysis linkage, when it is really just a forwarding/exposure placeholder. So every AoU exposed externally -- whether it is a dependable_element's own AoU (first-hop exposure, expose_own_aou_trlc.py) or one it received and is chain-forwarding further (filter_forwarded_trlc.py) -- is retyped to a new ScoreReq. ForwardedAoU record (same package + record name, a justification field injected), keeping every derived_from reference resolvable no matter how many hops away from the original owner it is. 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 the AoU's owner and on an intermediate element that chain-forwards it). TRLC's own duplicate-definition check keys on package + record name alone, not on type, so this would otherwise be rejected as a duplicate definition. dedupe_aou_trlc.py resolves this automatically wherever TrlcProviderInfo is merged across a deps list (dependable_element.bzl and requirements.bzl), collapsing any duplicate AoU/ForwardedAoU identity down to a single kept declaration before it ever reaches TRLC. - trlc/config/score_requirements_model.rsl: add ForwardedAoU type extending ControlMeasure; extend CompReqSourceId.item. - src/trlc_record_utils.py: shared .trlc parsing/retyping helpers used by filter_forwarded_trlc.py, expose_own_aou_trlc.py, and dedupe_aou_trlc.py. - src/filter_forwarded_trlc.py: retype chain-forwarded AoU/ForwardedAoU records to ForwardedAoU, hard-fail on unmatched aou_forwarding.yaml entries. - src/expose_own_aou_trlc.py: retype a dependable_element's own AoU records to ForwardedAoU for first-hop external exposure. - src/dedupe_aou_trlc.py + private/aou_trlc_dedupe.bzl: deduplicate AoU/ForwardedAoU records reaching one TRLC parse via more than one dependency path (diamond dependencies). - private/dependable_element.bzl: own_aou_trlc and received_aou_trlc_reqs_list now go through the new retype/dedupe actions before being exposed via TrlcProviderInfo. - private/requirements.bzl: dedupe merged TrlcProviderInfo.reqs/.deps across a target's own deps before compiling. - providers.bzl, docs/user_guide/assumptions_of_use.rst, docs/rule_reference.rst: document the ForwardedAoU synthesis and diamond deduplication guarantees. - New tests: test_dedupe_aou_trlc.py, test_expose_own_aou_trlc.py, updated test_filter_forwarded_trlc.py, and a permanent diamond- dependency regression fixture (component_requirements_diamond_aou). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bazel/rules/rules_score/BUILD | 53 ++++ .../rules/rules_score/docs/rule_reference.rst | 2 +- .../docs/user_guide/assumptions_of_use.rst | 84 ++++++- .../integrator/docs/requirements/BUILD | 11 +- .../lobster/config/lobster_aou.yaml | 5 + .../rules_score/private/aou_trlc_dedupe.bzl | 81 ++++++ .../private/dependable_element.bzl | 162 +++++++++++- .../rules_score/private/requirements.bzl | 22 +- bazel/rules/rules_score/providers.bzl | 73 +++++- .../rules/rules_score/src/dedupe_aou_trlc.py | 217 ++++++++++++++++ .../rules_score/src/expose_own_aou_trlc.py | 149 +++++++++++ .../rules_score/src/filter_forwarded_trlc.py | 213 ++++++++++++++++ .../rules_score/src/trlc_record_utils.py | 159 ++++++++++++ bazel/rules/rules_score/test/BUILD | 124 ++++++++++ .../aou_forwarding_select_temp.yaml | 20 ++ .../component_requirements_chain_aou.trlc | 23 ++ ...onent_requirements_chain_aou_negative.trlc | 11 + .../component_requirements_diamond_aou.trlc | 23 ++ .../component_requirements_direct_aou.trlc | 23 ++ .../rules_score/test/test_dedupe_aou_trlc.py | 128 ++++++++++ .../test/test_expose_own_aou_trlc.py | 96 ++++++++ .../test/test_filter_forwarded_trlc.py | 231 ++++++++++++++++++ .../trlc/config/score_requirements_model.rsl | 9 +- 23 files changed, 1898 insertions(+), 21 deletions(-) create mode 100644 bazel/rules/rules_score/private/aou_trlc_dedupe.bzl create mode 100644 bazel/rules/rules_score/src/dedupe_aou_trlc.py create mode 100644 bazel/rules/rules_score/src/expose_own_aou_trlc.py create mode 100644 bazel/rules/rules_score/src/filter_forwarded_trlc.py create mode 100644 bazel/rules/rules_score/src/trlc_record_utils.py create mode 100644 bazel/rules/rules_score/test/fixtures/seooc_test/aou_forwarding_select_temp.yaml create mode 100644 bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou.trlc create mode 100644 bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou_negative.trlc create mode 100644 bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_diamond_aou.trlc create mode 100644 bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_direct_aou.trlc create mode 100644 bazel/rules/rules_score/test/test_dedupe_aou_trlc.py create mode 100644 bazel/rules/rules_score/test/test_expose_own_aou_trlc.py create mode 100644 bazel/rules/rules_score/test/test_filter_forwarded_trlc.py 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/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..abdea401 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -963,6 +963,83 @@ 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, + architectural_design = [":arch_design_elem"], + # 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"], + aou_forwarding = "fixtures/seooc_test/aou_forwarding_select_temp.yaml", + components = [":test_component"], + dependability_analysis = [":dependability_analysis_target"], + deps = [":test_dependable_element"], + integrity_level = "B", + maturity = "development", + requirements = [":feat_req"], + tests = [], # Empty for testing +) + +# 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 +1852,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 +1931,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 +1945,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, + tags = ["manual"], + srcs = ["fixtures/seooc_test/component_requirements_chain_aou_negative.trlc"], + deps = [":test_dependable_element_aou_middle"], +) 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..dbc18b53 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/seooc_test/component_requirements_chain_aou_negative.trlc @@ -0,0 +1,11 @@ +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/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 .. *] } From 2ffd4b872691cb879610e5521506a81e21a390f6 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 21 Sep 2026 09:42:29 +0200 Subject: [PATCH 2/5] test(rules_score): add missing copyright header to AoU fixture component_requirements_chain_aou_negative.trlc was missing the required Eclipse Foundation copyright header, causing the copyright-check CI job to fail on PR #474. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../component_requirements_chain_aou_negative.trlc | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index dbc18b53..cf7d8995 100644 --- 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 @@ -1,3 +1,15 @@ +/******************************************************************************** + * 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 From 071ed335a34f3137a154abb2355c2246b99aed94 Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 21 Sep 2026 09:42:50 +0200 Subject: [PATCH 3/5] build(rules_score/test): register hermetic LLVM sysroot toolchain The test/ Bazel module (a separate module from the repo root, used for rules_score's own integration fixtures) had a bare 'llvm.toolchain()' extension usage with no matching 'llvm.sysroot()' call, unlike the root MODULE.bazel. This made local C++ compiles in this module (e.g. flatbuffers' flatc) fail with 'features.h file not found', since libc headers resolved against the host instead of a pinned sysroot -- masking real target failures behind an unrelated toolchain error whenever reproducing CI issues locally. Mirror the root MODULE.bazel's hermetic sysroot setup (apt.install + sysroot_from_lock + llvm.sysroot()), using uniquely-named repos (test_tooling_sysroot*) to avoid a bzlmod module-extension singleton collision with the root module's own identically-named apt.install(), and adopt the same llvm.toolchain() compile_flags/extra_link_libs as the root module for consistency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bazel/rules/rules_score/test/MODULE.bazel | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) 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( From dfc6e2fbc4fdde073bd698bc6c0b3f2e98d996bb Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 21 Sep 2026 09:43:21 +0200 Subject: [PATCH 4/5] test(rules_score): give test_dependable_element_aou_middle its own architecture fixture test_dependable_element_aou_middle previously reused ':arch_design_elem', whose .puml only declares a 'test_dependable_element' package. This passed analysis only because the (also-reusing) coverage fixtures are tagged 'manual' and never have their '_index' validation actually run; since this fixture is not manual, its '_index' target failed PlantUML architecture validation with 'Package "test_dependable_element_aou_middle" from Bazel not found in the PlantUML component diagram' -- the first thing masking the real CI failure when reproducing PR #474 locally. Add a dedicated fixtures/test_dependable_element_aou_middle.puml (package name matching the label) and a new arch_design_elem_aou_middle architectural_design target, and point the dependable_element at it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bazel/rules/rules_score/test/BUILD | 17 ++++++++++---- .../test_dependable_element_aou_middle.puml | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 bazel/rules/rules_score/test/fixtures/test_dependable_element_aou_middle.puml diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index abdea401..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"], @@ -973,7 +981,8 @@ dependable_element( dependable_element( name = "test_dependable_element_aou_middle", testonly = True, - architectural_design = [":arch_design_elem"], + 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 @@ -983,14 +992,13 @@ dependable_element( # "this element has its own AoUs *and* forwards different received ones" # without any accidental self-collision. assumptions_of_use = [":aous_rst"], - aou_forwarding = "fixtures/seooc_test/aou_forwarding_select_temp.yaml", components = [":test_component"], dependability_analysis = [":dependability_analysis_target"], - deps = [":test_dependable_element"], integrity_level = "B", maturity = "development", requirements = [":feat_req"], tests = [], # Empty for testing + deps = [":test_dependable_element"], ) # Regression/feature fixtures: downstream requirements targets referencing @@ -1039,7 +1047,6 @@ component_requirements( ], ) - # 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. @@ -1958,7 +1965,7 @@ test_suite( component_requirements( name = "comp_req_chain_aou_negative", testonly = True, - tags = ["manual"], 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/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 From 1476b8cc33a01e11d3396c89a309ac5f589b343b Mon Sep 17 00:00:00 2001 From: Ulrich Huber Date: Mon, 21 Sep 2026 09:46:07 +0200 Subject: [PATCH 5/5] fix(rules_score): give forwarded-AoU LOBSTER markers an independent tag namespace 'Forwarded AoUs' marker items (build_forwarded_markers) previously derived their tag by string-appending '__forwarded' onto the matched item's tag text, e.g. Tracing_Tag("req", f"{item.tag.tag}__forwarded", item.tag.version). This both implied the marker forks/derives a second identity from the AoU (it does not -- it is a distinct bookkeeping record for a forwarding decision, referencing the original via 'refs') and was outright buggy whenever the matched AoU id already contained an '@version' suffix: Tracing_Tag.from_text() splits on the *first* '@' it finds when a marker is read back from its .lobster JSON file, so the suffixed tag collapsed back onto the *original*, unsuffixed AoU tag on read-back, silently colliding with it in Tracing_Tag.key() (which ignores version). This produced 'duplicate definition' failures from lobster-report for any real fixture whose AoUs carry a version, as seen in PR #474's rules_score_tests CI failure. Fix by giving the marker a completely independent Tracing_Tag namespace ('aou_forwarding_marker' instead of 'req') while keeping the tag *text* identical to the original -- Tracing_Tag.key() is namespace + " " + tag, so a distinct namespace alone guarantees no collision, without ever mangling or suffixing the AoU's own tag text. Add a regression test that round-trips a marker built from a versioned AoU through actual .lobster JSON write/read (lobster_write/lobster_read) to lock in the fix at the same layer where the original bug manifested. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/aou_forwarding_to_lobster.py | 26 +++++++++++---- .../test/test_aou_forwarding_to_lobster.py | 32 ++++++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) 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/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"}]