diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 8a2ff0c0..47792d8e 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -109,6 +109,7 @@ py_binary( main = "src/aou_forwarding_to_lobster.py", visibility = ["//visibility:public"], deps = [ + "@lobster//lobster/common", requirement("pyyaml"), ], ) diff --git a/bazel/rules/rules_score/docs/_assets/aou_forwarding_one_seooc.puml b/bazel/rules/rules_score/docs/_assets/aou_forwarding_one_seooc.puml new file mode 100644 index 00000000..e560e242 --- /dev/null +++ b/bazel/rules/rules_score/docs/_assets/aou_forwarding_one_seooc.puml @@ -0,0 +1,56 @@ +' ******************************************************************************* +' 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 aou_forwarding_one_seooc + +' One SEooC receiving AoUs from its own deps, handling one locally, chain- +' forwarding another, and still contributing its own AoU untouched. + +skinparam linetype ortho +skinparam ArrowFontSize 10 +skinparam ArrowColor #555555 +skinparam defaultTextAlignment center + +skinparam rectangle { + BackgroundColor<> #E3F2FD + BorderColor<> #1565C0 + BackgroundColor<> #FFF8E1 + BorderColor<> #F9A825 + BackgroundColor<> #ECEFF1 + BorderColor<> #455A64 + BackgroundColor<> #E8F5E9 + BorderColor<> #2E7D32 + BackgroundColor<> #FCE4EC + BorderColor<> #AD1457 +} + +rectangle "dependency_a\ndefines AoU_1" <> as dep_a +rectangle "dependency_b\ndefines AoU_2" <> as dep_b + +rectangle "one_seooc" <> as seooc { + rectangle "received AoUs\n(each dep's own_aou_trlc +\nall_received_trlc, merged)" <> as received + rectangle "AoU_1 -- handled locally\n(CompReq.derived_from)" <> as handled + rectangle "AoU_2 -- chain-forwarded\n(aou_forwarding.yaml)" <> as forwarded + rectangle "own AoU_3\n(auto-forwarded)" <> as own + + received --> handled + received --> forwarded +} + +rectangle "dependee" <> as dependee + +dep_a --> seooc : deps (defines AoU_1) +dep_b --> seooc : deps (defines AoU_2) +seooc --> dependee : deps\n(forwards AoU_2 + AoU_3) + +@enduml diff --git a/bazel/rules/rules_score/docs/overview.rst b/bazel/rules/rules_score/docs/overview.rst index a2ef3241..15cd48ff 100644 --- a/bazel/rules/rules_score/docs/overview.rst +++ b/bazel/rules/rules_score/docs/overview.rst @@ -199,6 +199,78 @@ lacks full up/down traceability. "Root Causes" -> "Control Measures"; } +Execution Overview (Current Behavior) +------------------------------------- + +The table below summarizes how checks are currently executed in practice +(build-time action/analysis-time check vs. test-time executable). + +.. list-table:: + :header-rows: 1 + :widths: 26 16 58 + + * - Check + - Trigger + - Current execution path + * - Requirements validation (TRLC + model) + - test + - Executed by generated ``_test`` targets + (``trlc_requirements_test``); not run implicitly by building only the + enclosing ``dependable_element`` target. + * - Architecture consistency + - build + - Validation actions run in ``architectural_design``, ``unit``, and + ``dependable_element`` index assembly; build fails on violations + (or warns in ``maturity = "development"``). + * - Certified scope + - build + - Checked during dependable-element index analysis/assembly by traversing + transitive implementation dependencies against declared certified scopes. + * - Integrity level + - build + - Checked during dependable-element index analysis: a dependable element + must not depend on a lower-integrity dependable element. + * - Test case coverage lock + - build + - Per-component build action runs when the component provides + ``test_case_coverage_lock`` metadata; compares current gtest traceability + view against committed lock state. + * - Traceability report generation + - build + - LOBSTER config/report/RST artifacts are generated during build whenever + traceability inputs are present. + * - Traceability enforcement (``lobster-ci-report``) + - test + - Executed by ``bazel test`` on the dependable-element test target, using + the pre-built LOBSTER report. + * - Unit test execution used as traceability input + - build + - Unit test executables are run during build to collect gtest XML that is + converted into test traceability artifacts. + +Provider/log propagation used by dependable_element +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``ArchitecturalDesignInfo.validation_logs``: architectural-design validation + logs are forwarded and re-exposed under the dependable-element validation + output directory. +- ``UnitInfo.validation_log``: per-unit validation logs are forwarded and + symlinked into dependable-element outputs. +- ``ComponentTestCaseCoverageInfo``: presence of coverage-lock metadata on a + component enables the dependable-element-level coverage-lock build action. +- ``OutputGroupInfo(debug)``: dependable-element collects validation logs into a + debug output group, while still wiring required validation artifacts into + normal build outputs. + +Notes +~~~~~ + +- ``dependable_element(tests = [...])`` is currently a documented attribute, + but is not used to execute additional tests by the dependable-element rule + implementation. +- ``component(tests = [...])`` is currently declared, while traceability input + generation is driven by nested ``unit`` test artifacts. + Quick Reference --------------- diff --git a/bazel/rules/rules_score/docs/requirements/tool_requirements.trlc b/bazel/rules/rules_score/docs/requirements/tool_requirements.trlc index a47952f4..0150d03a 100644 --- a/bazel/rules/rules_score/docs/requirements/tool_requirements.trlc +++ b/bazel/rules/rules_score/docs/requirements/tool_requirements.trlc @@ -130,7 +130,7 @@ section "Tool Requirements" { The dependable_element rule shall automatically include lobster traceability entries for all assumptions of use defined by its direct dependencies in the dependee's traceability report as - a "Forwarded AoUs" tier. + a "Received AoUs" tier. ''' mitigates = [AoU_Silently_Dropped] derived_from = [Forward_AoU_To_Dependees] @@ -162,12 +162,32 @@ section "Tool Requirements" { satisfied_by = Tools.Bazel } + ToolQualification.ToolRequirement Cover_Received_AoU_Via_Component_Requirement { + description = ''' + The CompReq requirement type's derived_from field + (CompReqSourceId) shall accept versioned, typed cross-module + TRLC references to AoU records alongside its usual FeatReq/ + AssumedSystemReq references. Resolving an AoU reference shall + require the defining assumptions_of_use target to be listed + directly in the CompReq target's deps. The dependable_element + rule shall convert such references to lobster trace references + resolved against the "Received AoUs" tier, so a component + requirement can cover a received AoU it handles locally without + requiring that AoU to be chain-forwarded. + ''' + mitigates = [AoU_Silently_Dropped] + derived_from = [Forward_AoU_To_Dependees, Gate_Traceability_At_Test_Time] + satisfied_by = Tools.Lobster + } + ToolQualification.ToolRequirement Include_Forwarded_AoUs_In_Traceability { description = ''' - The lobster traceability report of a dependee shall include - forwarded AoUs as traceable items so that the existing - lobster-ci-report test fails when forwarded AoUs are not - handled (linked to a requirement, test, or justification). + The lobster traceability report of a dependable element shall + require every item in its "Received AoUs" tier to be covered + by at least one of: a component requirement (via + derived_from) or a chain-forwarding entry (via + aou_forwarding), so that the existing lobster-ci-report test + fails when a received AoU is neither handled nor forwarded. ''' mitigates = [AoU_Silently_Dropped] derived_from = [Forward_AoU_To_Dependees, Gate_Traceability_At_Test_Time] diff --git a/bazel/rules/rules_score/docs/rule_reference.rst b/bazel/rules/rules_score/docs/rule_reference.rst index c3d4fd44..ff28409a 100644 --- a/bazel/rules/rules_score/docs/rule_reference.rst +++ b/bazel/rules/rules_score/docs/rule_reference.rst @@ -274,6 +274,13 @@ testable within that component. **Generated targets:** ```` (documentation), ``_test`` (TRLC validation) +A ``CompReq`` record's ``derived_from`` field (``CompReqSourceId``) may also +reference an AoU that this requirement implements, alongside its usual +``FeatReq``/``AssumedSystemReq`` references (e.g. ``[SampleType.SampleAoU@1]``). +This requires importing the AoU's package and listing the +``assumptions_of_use`` target that defines it directly in this target's +``deps`` — see :doc:`user_guide/assumptions_of_use`. + .. _rule-assumptions-of-use: assumptions_of_use 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 f7da12c6..93d84e5c 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 @@ -21,7 +21,9 @@ or risk that is mitigated when this assumption is fulfilled. Traceability to requirements is established at the Bazel level via the ``deps`` attribute on the ``assumptions_of_use`` rule — there is no TRLC ``derived_from`` -or ``satisfies`` field on ``AoU``. +or ``satisfies`` field on ``AoU`` itself. A dependent component requirement can, +however, declare that it implements a received AoU by referencing it from its own +``derived_from`` field (see `AoU Forwarding`_ below). .. code-block:: text :caption: examples/seooc/docs/aous.trlc @@ -83,15 +85,76 @@ is forwarded rather than handled locally: calls to the library do not exceed the 10ms cycle time constraint imposed by the underlying other_seooc dependency. -**Handling forwarded AoUs in the dependee** -Forwarded AoUs appear as a "Forwarded AoUs" tier in the dependee's lobster -traceability report. The dependee must handle each forwarded AoU by one of: +**Handling AoUs received in the dependee** +Every AoU a dependable element receives appears as +an item in a "Received AoUs" tier in the dependee's lobster traceability +report. Each received AoU must be covered by exactly one of: + +- **Handling it locally**: a component requirement's ``derived_from`` field + references the AoU it implements (see below). This shows up as "Component + Requirements" coverage in the report. +- **Chain-forwarding it further** (with justification) via ``aou_forwarding``, + to be handled by this element's own dependees instead. This shows up as + "Forwarded AoUs" coverage in the report. + +If a received AoU is neither handled nor forwarded, the ``bazel test`` +traceability check fails. + +A single dependable element can do all three at once — receive AoUs from its +own dependencies, handle some of them locally, chain-forward the rest, and +still contribute its own AoUs to the mix: + +.. uml:: ../_assets/aou_forwarding_one_seooc.puml + +**Handling a received AoU with a component requirement** +Add a typed, versioned reference to the AoU (``Package.RecordName@version``, +matching the upstream ``AoU`` TRLC record) to the ``derived_from`` field of +the ``CompReq`` that implements it, alongside any ``FeatReq``/ +``AssumedSystemReq`` references — all three item kinds share the same field. +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. -- Linking it to a component requirement that addresses the assumption -- Linking it to a test that verifies the assumption is met -- Chain-forwarding it further (with justification) to its own dependees +.. code-block:: text + :caption: examples/integrator/docs/requirements/component_requirements.trlc + + package IntegratorComponent + + import ScoreReq + import Integrator + import SampleType + + ScoreReq.CompReq COMP_INT_001 { + description = "The startup module shall call the SEooC initialization routine before entering the main loop" + safety = ScoreReq.Asil.B + derived_from = [Integrator.FEAT_INT_001@1, SampleType.SampleAoU@1] + version = 1 + } + +.. code-block:: starlark + :caption: examples/integrator/docs/requirements/BUILD + + component_requirements( + name = "component_requirements", + srcs = ["component_requirements.trlc"], + testonly = True, + deps = [ + ":feature_requirements", + "@seooc//docs:sample_aous", + "@some_other_library//:other_library_aous", + ], + ) -If a forwarded AoU is not handled, the ``bazel test`` traceability check will fail. +Being a real TRLC reference, an AoU entry in ``derived_from`` is resolved (and +a typo or an AoU this element does not actually receive is rejected) by the +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. **Example: three-level forwarding chain** (the real working code for this example lives in ``examples/some_other_library``, ``examples/seooc``, and @@ -105,7 +168,8 @@ example lives in ``examples/some_other_library``, ``examples/seooc``, and → chain-forwards received TimingConstraint via aou_forwarding.yaml ↑ (deps) integrator_seooc → receives SampleType.SampleAoU (auto-forwarded) - and OtherLibrary.TimingConstraint (chain-forwarded), must handle both + and OtherLibrary.TimingConstraint (chain-forwarded) + → handles both locally via derived_from (no further dependees) .. code-block:: starlark :caption: examples/seooc/BUILD diff --git a/bazel/rules/rules_score/docs/user_guide/general.rst b/bazel/rules/rules_score/docs/user_guide/general.rst index 584100bb..0b02c25d 100644 --- a/bazel/rules/rules_score/docs/user_guide/general.rst +++ b/bazel/rules/rules_score/docs/user_guide/general.rst @@ -42,8 +42,8 @@ A *dependable element* is the top-level entity: - Functional and safety requirements for this element * - Assumptions of Use - Conditions the integrating project must satisfy - * - Forwarded AoUs - - Assumptions of use received from dependencies that must be handled or forwarded further + * - Received AoUs + - Assumptions of use received from dependencies that must be handled locally or chain-forwarded further * - Architectural Design - Software Architectural Design in PlantUML * - Software Units and Components diff --git a/bazel/rules/rules_score/examples/integrator/BUILD b/bazel/rules/rules_score/examples/integrator/BUILD index c963f8ad..16e9055b 100644 --- a/bazel/rules/rules_score/examples/integrator/BUILD +++ b/bazel/rules/rules_score/examples/integrator/BUILD @@ -13,7 +13,8 @@ # # Example: System integrator SEooC that depends on safety_software_seooc_example. # -# This illustrates the full AoU forwarding chain: +# This illustrates the full AoU forwarding chain, and the two ways a +# dependable element can cover an AoU it receives: # # other_seooc defines AoU: OtherLibrary.TimingConstraint # ↑ (deps) @@ -22,9 +23,9 @@ # - chain-forwards OtherLibrary.TimingConstraint via aou_forwarding.yaml # ↑ (deps) # integrator_seooc (this target) -# - receives SampleType.SampleAoU (auto-forwarded from seooc) -# - receives OtherLibrary.TimingConstraint (chain-forwarded through seooc) -# - must handle both in its lobster traceability report +# - receives SampleType.SampleAoU (auto-forwarded from seooc) +# - receives OtherLibrary.TimingConstraint (chain-forwarded through seooc) +# - HANDLES both locally: COMP_INT_001 and COMP_INT_002 # load( diff --git a/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD b/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD index 50d8c6e9..9423d6f8 100644 --- a/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD +++ b/bazel/rules/rules_score/examples/integrator/docs/requirements/BUILD @@ -40,5 +40,7 @@ component_requirements( visibility = ["//visibility:public"], deps = [ ":feature_requirements", + "@seooc//docs:sample_aous", + "@some_other_library//:other_library_aous", ], ) diff --git a/bazel/rules/rules_score/examples/integrator/docs/requirements/component_requirements.trlc b/bazel/rules/rules_score/examples/integrator/docs/requirements/component_requirements.trlc index b3becb90..d9fb84bb 100644 --- a/bazel/rules/rules_score/examples/integrator/docs/requirements/component_requirements.trlc +++ b/bazel/rules/rules_score/examples/integrator/docs/requirements/component_requirements.trlc @@ -14,18 +14,20 @@ package IntegratorComponent import ScoreReq import Integrator +import SampleType +import OtherLibrary ScoreReq.CompReq COMP_INT_001 { description = "The startup module shall call the SEooC initialization routine before entering the main loop" safety = ScoreReq.Asil.B - derived_from = [Integrator.FEAT_INT_001@1] + derived_from = [Integrator.FEAT_INT_001@1, SampleType.SampleAoU@1] version = 1 } ScoreReq.CompReq COMP_INT_002 { - description = "The cyclic task shall invoke the validation interface every 10ms using a hardware timer interrupt" + description = "The cyclic task shall invoke the validation interface every 10ms using a hardware timer interrupt, satisfying the underlying library's timing constraint" safety = ScoreReq.Asil.B - derived_from = [Integrator.FEAT_INT_002@1] + derived_from = [Integrator.FEAT_INT_002@1, OtherLibrary.TimingConstraint@1] version = 1 } diff --git a/bazel/rules/rules_score/examples/seooc/BUILD b/bazel/rules/rules_score/examples/seooc/BUILD index c42ad63b..de798362 100644 --- a/bazel/rules/rules_score/examples/seooc/BUILD +++ b/bazel/rules/rules_score/examples/seooc/BUILD @@ -45,6 +45,7 @@ component( "//docs/requirements:component_requirements", "//docs/requirements:component_requirements_extra", "//docs/requirements:component_requirements_sub", + "//docs/requirements:feature_requirements", ], tags = ["manual"], test_case_coverage_lock = "test_case_coverage.lock.yaml", diff --git a/bazel/rules/rules_score/examples/some_other_library/BUILD b/bazel/rules/rules_score/examples/some_other_library/BUILD index 47313b5c..f1d70c7b 100644 --- a/bazel/rules/rules_score/examples/some_other_library/BUILD +++ b/bazel/rules/rules_score/examples/some_other_library/BUILD @@ -30,6 +30,7 @@ architectural_design( assumptions_of_use( name = "other_library_aous", srcs = ["aous.trlc"], + visibility = ["//visibility:public"], ) unit( diff --git a/bazel/rules/rules_score/lobster/config/lobster_de.conf.tpl b/bazel/rules/rules_score/lobster/config/lobster_de.conf.tpl index df39df77..57e56b26 100644 --- a/bazel/rules/rules_score/lobster/config/lobster_de.conf.tpl +++ b/bazel/rules/rules_score/lobster/config/lobster_de.conf.tpl @@ -1,5 +1,7 @@ {FEAT_REQ_BLOCK} +{RECEIVED_AOU_BLOCK} + {FORWARDED_AOU_BLOCK} {COMP_REQ_BLOCK} diff --git a/bazel/rules/rules_score/private/component.bzl b/bazel/rules/rules_score/private/component.bzl index 650e2dd1..d9d9ff46 100644 --- a/bazel/rules/rules_score/private/component.bzl +++ b/bazel/rules/rules_score/private/component.bzl @@ -181,19 +181,31 @@ def _component_impl(ctx): # ------------------------------------------------------------------------- # Lobster Tracing: collect .lobster files from component_requirements targets - # and feature_requirements targets (needed to resolve derived_from references) + # and feature_requirements targets (needed to resolve derived_from references). # ------------------------------------------------------------------------- req_lobster_files = [] + req_lobster_transitive_files = [] feat_req_lobster_files = [] for req_target in ctx.attr.requirements: if ComponentRequirementsInfo in req_target: req_lobster_files.append(req_target[ComponentRequirementsInfo].srcs) + req_lobster_transitive_files.append(req_target[ComponentRequirementsInfo].srcs) if FeatureRequirementsInfo in req_target: feat_req_lobster_files.append(req_target[FeatureRequirementsInfo].srcs) if AssumedSystemRequirementsInfo in req_target: feat_req_lobster_files.append(req_target[AssumedSystemRequirementsInfo].srcs) + # Bubble up requirement lobster files from nested components so + # dependable_element sees the full tree, not just this component's own + # requirements (ComponentInfo.requirements itself is intentionally scoped + # to only this component's own explicitly-listed requirements). + for component in ctx.attr.components: + if ComponentInfo in component: + if component[ComponentInfo].requirements_transitive: + req_lobster_transitive_files.append(component[ComponentInfo].requirements_transitive) + req_lobster_depset = depset(transitive = req_lobster_files) + req_lobster_transitive_depset = depset(transitive = req_lobster_transitive_files) feat_req_lobster_depset = depset(transitive = feat_req_lobster_files) # Collect nested components @@ -314,6 +326,7 @@ def _component_impl(ctx): ComponentInfo( name = ctx.label.name, requirements = req_lobster_depset, + requirements_transitive = req_lobster_transitive_depset, components = components_depset, tests = depset( [gtest_lobster_file], diff --git a/bazel/rules/rules_score/private/component_requirements.bzl b/bazel/rules/rules_score/private/component_requirements.bzl index 79a86b63..fd138eff 100644 --- a/bazel/rules/rules_score/private/component_requirements.bzl +++ b/bazel/rules/rules_score/private/component_requirements.bzl @@ -49,13 +49,19 @@ def component_requirements( in the S-CORE requirements model. deps: Optional list of requirement targets (e.g. assumed_system_requirements, feature_requirements) whose TRLC records are needed for cross-reference - parsing. These targets must provide TrlcProviderInfo. + parsing. Also list any assumptions_of_use target(s) here (this + element's own, or one received/forwarded from a dependable_element + dependency) to resolve AoU references in `derived_from` + (CompReqSourceId). These targets must provide TrlcProviderInfo. spec: TRLC specification target(s) providing RSL type definitions. Accepts a single label or a list of labels; all are merged into the spec passed to TRLC. Defaults to the S-CORE requirements model (``@score_tooling//bazel/rules/rules_score/trlc/config:score_requirements_model``). lobster_config: Optional Lobster extraction config label. Defaults to the - S-CORE component requirement config. + S-CORE component requirement config. Its conversion rules always + include `derived_from` as a tracing target, including any AoU + entries within it (resolved only at the dependable_element level, + which has a "Received AoUs" level). visibility: Bazel visibility specification for the generated targets. Generated Targets: diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index 35e917be..00d5a237 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -753,6 +753,15 @@ def _symlink_validation_log(ctx, validation_log): # Index Generation Rule Implementation # ============================================================================ +def _cond_names(*conditioned_names): + """Flatten (condition, level_name) pairs into a name list, dropping false conditions. + + Reduces the `(["X"] if cond else []) + (["Y"] if cond2 else [])` + repetition used to build `trace_to`/`requires` level-name lists below to + a flat, readable argument list. + """ + return [name for condition, name in conditioned_names if condition] + def _dependable_element_index_impl(ctx): """Generate index.rst file with references to all dependable element artifacts. @@ -1059,15 +1068,14 @@ def _dependable_element_index_impl(ctx): feat_req_lobster_depset = depset(transitive = feat_req_lobster_files) - # Collect component requirement and test .lobster files from ComponentInfo comp_req_lobster_files = [] comp_test_lobster_files = [] comp_arch_lobster_files = [] for comp_target in ctx.attr.components: if ComponentInfo in comp_target: comp_info = comp_target[ComponentInfo] - if comp_info.requirements: - comp_req_lobster_files.append(comp_info.requirements) + if comp_info.requirements_transitive: + comp_req_lobster_files.append(comp_info.requirements_transitive) if comp_info.tests: comp_test_lobster_files.append(comp_info.tests) if comp_info.architecture: @@ -1160,6 +1168,7 @@ def _dependable_element_index_impl(ctx): # Build the DE-level lobster report if feature and component traces exist feat_req_list = feat_req_lobster_depset.to_list() + comp_req_list = comp_req_lobster_depset.to_list() comp_test_list = comp_test_lobster_depset.to_list() comp_arch_list = comp_arch_lobster_depset.to_list() @@ -1172,7 +1181,7 @@ def _dependable_element_index_impl(ctx): # AoU Forwarding: collect own AoUs and received AoUs from deps # ========================================================================= - # Collect own AoU lobster files from assumptions_of_use targets + # Collect own AoU lobster files from assumptions_of_use targets. own_aou_lobster_files = [] for aou_target in ctx.attr.assumptions_of_use: if AssumptionsOfUseInfo in aou_target: @@ -1180,11 +1189,6 @@ def _dependable_element_index_impl(ctx): own_aou_lobster_depset = depset(transitive = own_aou_lobster_files) - own_assumptions_of_use_info = AssumptionsOfUseInfo( - aou_lobster = own_aou_lobster_depset, - name = ctx.label.name, - ) - # Collect forwarded AoU lobster files from deps (received AoUs) received_aou_lobster_files = [] for dep in ctx.attr.processed_deps: @@ -1196,27 +1200,34 @@ def _dependable_element_index_impl(ctx): received_aou_lobster_depset = depset(transitive = received_aou_lobster_files) received_aou_list = received_aou_lobster_depset.to_list() - # Chain-forwarding: if aou_forwarding YAML is provided, filter received AoUs + # Chain-forwarding: if aou_forwarding YAML is provided, filter received AoUs. chain_forwarded_lobster_depset = depset() + forwarded_aou_markers_list = [] if ctx.file.aou_forwarding and received_aou_list: chain_forwarded_lobster_file = ctx.actions.declare_file( ctx.label.name + "/chain_forwarded_aous.lobster", ) + forwarded_aou_markers_file = ctx.actions.declare_file( + ctx.label.name + "/forwarded_aou_markers.lobster", + ) fwd_args = ctx.actions.args() fwd_args.add("--yaml", ctx.file.aou_forwarding) fwd_args.add("--output", chain_forwarded_lobster_file) + fwd_args.add("--markers-output", forwarded_aou_markers_file) fwd_args.add("--input-lobster") fwd_args.add_all(received_aou_list) ctx.actions.run( inputs = [ctx.file.aou_forwarding] + received_aou_list, - outputs = [chain_forwarded_lobster_file], + outputs = [chain_forwarded_lobster_file, forwarded_aou_markers_file], executable = ctx.executable._aou_forwarding_tool, arguments = [fwd_args], progress_message = "Filtering chain-forwarded AoUs for %s" % ctx.label.name, mnemonic = "AoUForwarding", ) chain_forwarded_lobster_depset = depset([chain_forwarded_lobster_file]) + forwarded_aou_markers_list = [forwarded_aou_markers_file] output_files.append(chain_forwarded_lobster_file) + output_files.append(forwarded_aou_markers_file) lobster_report_file = None lobster_html_report = None @@ -1272,6 +1283,7 @@ def _dependable_element_index_impl(ctx): has_feat_req = bool(feat_req_list) has_comp_req = bool(comp_req_list) + has_received_aou = bool(received_aou_list) has_public_api = bool(interface_req_list) or strict has_fm = bool(fm_list) or strict has_cm = bool(cm_list) or strict @@ -1283,32 +1295,65 @@ def _dependable_element_index_impl(ctx): output = lobster_config, substitutions = { "{FEAT_REQ_BLOCK}": format_lobster_block("requirements", "Feature Requirements", feat_req_list), - "{FORWARDED_AOU_BLOCK}": format_lobster_block("requirements", "Forwarded AoUs", received_aou_list), + # Target level: every item here must be covered, either by a + # Component Requirement (`derived_from`) or by being + # further chain-forwarded (Forwarded AoUs, below). Without the + # `requires` override, LOBSTER would instead require BOTH + # sources to independently cover every item (its default + # AND-across-sources behaviour for multiple `trace to:` + # declarations), which no single AoU could ever satisfy. + "{RECEIVED_AOU_BLOCK}": format_lobster_block( + "requirements", + "Received AoUs", + received_aou_list, + requires = [_cond_names( + (has_comp_req, "Component Requirements"), + (forwarded_aou_markers_list or strict, "Forwarded AoUs"), + )], + ), + # Checking level: markers for received AoUs this element chain- + # forwards onward instead of handling locally. Force-emitted + # empty in release mode (when there are received AoUs) so the + # "trace to: Received AoUs" edge stays active even without an + # aou_forwarding.yaml -- otherwise an element that receives + # AoUs but neither forwards nor handles any of them would have + # no checking level at all, and the missing coverage would + # silently pass instead of failing the build. + "{FORWARDED_AOU_BLOCK}": format_lobster_block( + "requirements", + "Forwarded AoUs", + forwarded_aou_markers_list, + trace_to = _cond_names((has_received_aou, "Received AoUs")), + emit_empty = strict and has_received_aou, + ), "{COMP_REQ_BLOCK}": format_lobster_block( "requirements", "Component Requirements", comp_req_list, - trace_to = ["Feature Requirements"] if has_feat_req else [], + trace_to = _cond_names( + (has_feat_req, "Feature Requirements"), + (has_received_aou, "Received AoUs"), + ), ), "{UNIT_TEST_BLOCK}": format_lobster_block( "activity", "Unit Test", comp_test_list, - trace_to = ["Component Requirements"] if has_comp_req else [], + trace_to = _cond_names((has_comp_req, "Component Requirements")), emit_empty = strict, ), "{COVERAGE_BLOCK}": format_lobster_block( "activity", "Test Case Coverage", coverage_lobster_files, - trace_to = ["Component Requirements"] if has_comp_req else [], + trace_to = _cond_names((has_comp_req, "Component Requirements")), emit_empty = strict, ), "{ARCH_BLOCK}": format_lobster_block( "implementation", "Architecture", comp_arch_list, - trace_to = ["Component Requirements"] if has_comp_req else [], + trace_to = _cond_names((has_comp_req, "Component Requirements")), emit_empty = strict, ), "{PUBLIC_API_BLOCK}": format_lobster_block( @@ -1321,7 +1366,7 @@ def _dependable_element_index_impl(ctx): "requirements", "Failure Modes", fm_list, - trace_to = ["Public API"] if has_public_api else [], + trace_to = _cond_names((has_public_api, "Public API")), emit_empty = strict, ), "{CM_BLOCK}": format_lobster_block( @@ -1334,13 +1379,13 @@ def _dependable_element_index_impl(ctx): "activity", "Root Causes", rc_list, - trace_to = (["Failure Modes"] if has_fm else []) + (["Control Measures"] if has_cm else []), + trace_to = _cond_names((has_fm, "Failure Modes"), (has_cm, "Control Measures")), emit_empty = strict, ), }, ) - all_lobster_inputs = feat_req_list + comp_req_list + comp_arch_list + comp_test_list + interface_req_list + fm_list + cm_list + rc_list + received_aou_list + coverage_lobster_files + all_lobster_inputs = feat_req_list + comp_req_list + comp_arch_list + comp_test_list + interface_req_list + fm_list + cm_list + rc_list + received_aou_list + forwarded_aou_markers_list + coverage_lobster_files lobster_report_file = subrule_lobster_report(all_lobster_inputs, lobster_config) lobster_files = [lobster_config, lobster_report_file] @@ -1436,7 +1481,6 @@ def _dependable_element_index_impl(ctx): own_aou_lobster = own_aou_lobster_depset, chain_forwarded_lobster = chain_forwarded_lobster_depset, ), - own_assumptions_of_use_info, OutputGroupInfo(debug = depset(validation_output_files + unit_validation_output_files)), ] diff --git a/bazel/rules/rules_score/private/lobster_config.bzl b/bazel/rules/rules_score/private/lobster_config.bzl index d201e084..7e8188fb 100644 --- a/bazel/rules/rules_score/private/lobster_config.bzl +++ b/bazel/rules/rules_score/private/lobster_config.bzl @@ -32,7 +32,7 @@ def format_lobster_sources(files): """ return "\n".join([' source: "{}";'.format(f.path) for f in files]) -def format_lobster_block(kind, name, files, trace_to = [], emit_empty = False): +def format_lobster_block(kind, name, files, trace_to = [], emit_empty = False, requires = []): """Build a complete, optional LOBSTER config block. By default returns "" (omitting the level entirely) when `files` is empty, @@ -48,6 +48,17 @@ def format_lobster_block(kind, name, files, trace_to = [], emit_empty = False): a missing verification level (e.g. no unit tests, no root causes) fail the traceability check instead of silently disappearing from the policy. + By default, when *multiple* levels each declare `trace to: `, + LOBSTER requires ALL of them to independently cover every item here (an + AND across sources) -- see the `requires` config directive in LOBSTER's + documentation. Pass `requires` to relax this to an OR: each element is a + list of level names, rendered as a single `requires: "A" or "B";` line + (multiple elements become separate, independently-AND'd `requires:` + lines). Only include level names that are themselves present (non-empty + or emit_empty) in the same config, and that already declare a `trace to:` + line pointing at this level (`requires` narrows an existing mandatory + `trace to:` edge into an alternative, it does not create one). + Args: kind: LOBSTER block kind, e.g. "requirements", "activity", "implementation". name: Level name, used as the quoted block header. @@ -60,6 +71,10 @@ def format_lobster_block(kind, name, files, trace_to = [], emit_empty = False): emit_empty: When True, emit the block header and `trace to:` lines even if `files` is empty (no `source:` lines are produced). Defaults to False. + requires: List of OR-groups (each a list of level names) that jointly + replace the default AND-of-sources coverage check for this level + with "any one of these is sufficient". Defaults to [] (no + override; the default AND-of-sources behaviour applies). Returns: The full block text (kind, header, sources, trace-to lines), or "" @@ -68,9 +83,15 @@ def format_lobster_block(kind, name, files, trace_to = [], emit_empty = False): if not files and not emit_empty: return "" trace_lines = "".join([' trace to: "{}";\n'.format(t) for t in trace_to]) - return '{kind} "{name}" {{\n{sources}\n{trace}}}'.format( + requires_lines = "".join([ + " requires: {};\n".format(" or ".join(['"{}"'.format(n) for n in group])) + for group in requires + if group + ]) + return '{kind} "{name}" {{\n{sources}\n{trace}{requires}}}'.format( kind = kind, name = name, sources = format_lobster_sources(files), trace = trace_lines, + requires = requires_lines, ) diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl index b94d1692..908a725b 100644 --- a/bazel/rules/rules_score/providers.bzl +++ b/bazel/rules/rules_score/providers.bzl @@ -99,7 +99,7 @@ FeatureRequirementsInfo = provider( ComponentRequirementsInfo = provider( doc = "Provider for component requirements artifacts.", fields = { - "srcs": "Depset of .lobster traceability files generated from TRLC requirement sources.", + "srcs": "Depset of .lobster traceability files generated from TRLC requirement sources. Includes `derived_from` as a tracing target, including any AoU references within it.", "name": "Name of the requirements target.", }, ) @@ -146,7 +146,8 @@ ComponentInfo = provider( doc = "Provider for component artifacts.", fields = { "name": "Name of the component target.", - "requirements": "Depset of component requirement traceability files (.lobster) collected from ComponentRequirementsInfo targets only (CompReq kind). Does not include feature or assumed-system requirement files.", + "requirements": "Depset of component requirement traceability files (.lobster) collected from this component's own ComponentRequirementsInfo targets only (CompReq kind); not rolled up from nested components. Does not include feature or assumed-system requirement files. Consumed only by dependable_element's test-case-coverage-lock check. Includes `derived_from` tracing targets, including any AoU references within it (resolved only at the dependable_element level).", + "requirements_transitive": "Depset of component requirement traceability files (.lobster), rolled up transitively from this component and all nested components. Consumed by dependable_element to build the aggregated 'Component Requirements' traceability report tier.", "components": "Depset of nested component and/or unit Targets that comprise this component.", "tests": "Depset of test traceability files (.lobster) generated from unit test results, collected transitively from all nested components and units.", "architecture": "Depset of architecture traceability files (.lobster) generated from unit architectural designs, collected transitively from all nested components and units.", 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 8c9e50c9..042da0d6 100644 --- a/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py +++ b/bazel/rules/rules_score/src/aou_forwarding_to_lobster.py @@ -16,15 +16,42 @@ files, then outputs a new .lobster file containing only the entries listed in the YAML. This enables dependable elements to further-forward AoUs they cannot handle to their own dependees. + +Optionally also emits a second "markers" .lobster file: one synthetic item +per forwarded entry, distinct from (but referencing) the original received +AoU item. This is what lets the dependable_element's own traceability report +show a "Forwarded AoUs" level with a `trace to: "Received AoUs"` edge — +using the original (identity-preserved) items directly would create a tag +collision with the "Received AoUs" level in the same report. + +Reuses ``Requirement``, ``Tracing_Tag``, ``File_Reference``, ``lobster_read``, +and ``lobster_write`` from the lobster library (no manual JSON construction +or envelope/schema handling) — only the YAML parsing and the AoU-ID-to-tag +matching (which lobster itself has no concept of) are specific to this tool. """ from __future__ import annotations import argparse -import json +import logging from pathlib import Path import yaml +from lobster.common.errors import LOBSTER_Error, Message_Handler +from lobster.common.io import lobster_read, lobster_write +from lobster.common.items import Requirement, Tracing_Tag +from lobster.common.location import File_Reference + +GENERATOR = "aou_forwarding_to_lobster" + +logger = logging.getLogger(__name__) + +_LEVEL_MAP = { + "error": logging.ERROR, + "warn": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} def parse_forwarding_yaml(yaml_path: str) -> list[dict[str, str]]: @@ -66,34 +93,40 @@ def parse_forwarding_yaml(yaml_path: str) -> list[dict[str, str]]: ) result.append({"aou_id": aou_id, "justification": justification}) + logger.info("Parsed %d forwarding entr%s from %s", len(result), "y" if len(result) == 1 else "ies", yaml_path) return result -def load_lobster_items(lobster_paths: list[str]) -> list[dict]: - """Load all items from one or more .lobster JSON files. +def load_lobster_items(lobster_paths: list[str]) -> list[Requirement]: + """Load all Requirement items from one or more .lobster JSON files. Args: lobster_paths: Paths to .lobster files. Returns: - List of all lobster item dicts from all files. + List of all Requirement items from all files. + + Raises: + SystemExit: If a file cannot be read, or is not valid lobster-req-trace JSON. """ - all_items = [] + mh = Message_Handler() + all_items: list[Requirement] = [] for path in lobster_paths: + items: dict = {} try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError) as e: + lobster_read(mh, path, "aou", items) + except (OSError, LOBSTER_Error) as e: raise SystemExit(f"Failed to parse lobster file {path}: {e}") from e - all_items.extend(data.get("data", [])) + all_items.extend(items.values()) + logger.info("Loaded %d lobster item(s) from %d file(s)", len(all_items), len(lobster_paths)) return all_items -def filter_forwarded_aous( +def _match_forwarded_entries( forwarding_entries: list[dict[str, str]], - lobster_items: list[dict], -) -> list[dict]: - """Filter lobster items to only those listed in the forwarding YAML. + lobster_items: list[Requirement], +) -> list[tuple[dict[str, str], Requirement]]: + """Match each forwarding YAML entry to its received AoU lobster item. Matches by checking if the AoU ID appears in the lobster item's tag. Lobster-trlc generates tags like "req PackageName.RecordName@version". @@ -105,48 +138,113 @@ def filter_forwarded_aous( lobster_items: All lobster items from received AoU files. Returns: - Filtered list of lobster items matching the forwarding entries. + List of (entry, matched item) pairs, in forwarding YAML order. Raises: SystemExit: If any aou_id from YAML doesn't match a received item. """ # Build lookup: tag suffix -> item # Lobster-trlc may generate versioned tags like "req Pkg.Name@1". - # We index by both the full ID and the base ID (without @version). - item_by_id: dict[str, dict] = {} + # We index by both the full ID and the base ID (without @version), but + # only register the bare base ID when it is unambiguous: if two received + # items share the same base ID at different versions (e.g. the same AoU + # forwarded at different versions via different deps), a bare YAML + # reference must not silently resolve to whichever one loaded last. + item_by_id: dict[str, Requirement] = {} + base_id_versions: dict[str, dict[object, Requirement]] = {} for item in lobster_items: - tag = item.get("tag", "") - # Tags are formatted as "req PackageName.RecordName[@version]" - parts = tag.split(" ", 1) - if len(parts) == 2: - full_id = parts[1] - item_by_id[full_id] = item - # Also index by base name (strip @version suffix) - base_id = full_id.split("@")[0] - if base_id != full_id: - item_by_id[base_id] = item - - filtered = [] + full_id = item.tag.tag + if item.tag.version: + full_id += f"@{item.tag.version}" + item_by_id[full_id] = item + base_id_versions.setdefault(item.tag.tag, {})[item.tag.version] = item + + for base_id, by_version in base_id_versions.items(): + if len(by_version) == 1: + item_by_id[base_id] = next(iter(by_version.values())) + + matched = [] for entry in forwarding_entries: aou_id = entry["aou_id"] if aou_id not in item_by_id: available = ", ".join(sorted(item_by_id.keys())) if item_by_id else "(none)" + hint = "" + if aou_id in base_id_versions and len(base_id_versions[aou_id]) > 1: + hint = " (ambiguous: multiple versions received -- specify '@version' explicitly)" raise SystemExit( - f"AoU ID '{aou_id}' listed in forwarding YAML not found in received AoUs. Available IDs: {available}" + f"AoU ID '{aou_id}' listed in forwarding YAML not found in received AoUs.{hint} Available IDs: {available}" ) - filtered.append(item_by_id[aou_id]) + matched.append((entry, item_by_id[aou_id])) - return filtered + logger.info("Matched %d/%d forwarding entries to received AoU items", len(matched), len(forwarding_entries)) + return matched -def create_lobster_output(items: list[dict]) -> dict: - """Wrap items in the standard lobster JSON envelope.""" - return { - "schema": "lobster-req-trace", - "version": 3, - "generator": "aou_forwarding_to_lobster", - "data": items, - } +def filter_forwarded_aous( + forwarding_entries: list[dict[str, str]], + lobster_items: list[Requirement], +) -> list[Requirement]: + """Filter lobster items to only those listed in the forwarding YAML. + + The returned items are identical (same tag) to the originals: this + output is handed on, unmodified, to this element's own dependees so + that further chain-forwarding and eventual handling still resolves + against the AoU's original tag. + + Args: + forwarding_entries: Parsed YAML entries with 'aou_id' fields. + lobster_items: All lobster items from received AoU files. + + Returns: + Filtered list of lobster items matching the forwarding entries. + + Raises: + SystemExit: If any aou_id from YAML doesn't match a received item. + """ + return [item for _, item in _match_forwarded_entries(forwarding_entries, lobster_items)] + + +def build_forwarded_markers( + forwarding_entries: list[dict[str, str]], + lobster_items: list[Requirement], + yaml_path: str, +) -> 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. + + Args: + forwarding_entries: Parsed YAML entries with 'aou_id' and + 'justification' fields. + lobster_items: All lobster items from received AoU files. + yaml_path: Path to the aou_forwarding.yaml file (used as the + marker's source location). + + Returns: + List of marker Requirement items, one per forwarding entry. + + Raises: + SystemExit: If any aou_id from YAML doesn't match a received item. + """ + 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"), + location=File_Reference(yaml_path, line=1), + framework="AoUForwarding", + kind="ForwardedAoU", + name=aou_id, + text=entry["justification"], + ) + marker.add_tracing_target(item.tag) + markers.append(marker) + return markers def main() -> None: @@ -168,8 +266,23 @@ def main() -> None: required=True, help="Output .lobster file path for the filtered entries.", ) + parser.add_argument( + "--markers-output", + required=False, + help="Optional output .lobster file path for synthetic 'Forwarded AoUs' " + "marker items (distinct tags, refs pointing at the original received " + "AoU items). Used by the dependable_element's own traceability report.", + ) + 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") # Parse YAML forwarding_entries = parse_forwarding_yaml(args.yaml) @@ -177,16 +290,23 @@ def main() -> None: # Load received lobster items lobster_items = load_lobster_items(args.input_lobster) - # Filter + # Filter (identity-preserved copies, forwarded on to this element's own dependees) filtered_items = filter_forwarded_aous(forwarding_entries, lobster_items) # Write output - output = create_lobster_output(filtered_items) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: - json.dump(output, f, indent=2) - f.write("\n") + lobster_write(f, Requirement, GENERATOR, filtered_items) + logger.info("Wrote %d item(s) to %s", len(filtered_items), output_path) + + if args.markers_output: + markers = build_forwarded_markers(forwarding_entries, lobster_items, args.yaml) + markers_output_path = Path(args.markers_output) + markers_output_path.parent.mkdir(parents=True, exist_ok=True) + with open(markers_output_path, "w", encoding="utf-8") as f: + lobster_write(f, Requirement, GENERATOR, markers) + logger.info("Wrote %d marker item(s) to %s", len(markers), markers_output_path) if __name__ == "__main__": diff --git a/bazel/rules/rules_score/src/test_case_coverage/assets/component_diagram.puml b/bazel/rules/rules_score/src/test_case_coverage/assets/component_diagram.puml index 0bb54fbe..ee2dc8fa 100644 --- a/bazel/rules/rules_score/src/test_case_coverage/assets/component_diagram.puml +++ b/bazel/rules/rules_score/src/test_case_coverage/assets/component_diagram.puml @@ -24,7 +24,7 @@ package "Inputs" { package "component rule\n(component.bzl)" { [subrule_lobster_gtest] as subrule - [_component_test\n(test_case_coverage_lock attr)] as rule_test + [_component_rule\n(test_case_coverage_lock attr)] as rule_test [_component_update\n(.update target)] as rule_update } diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 8f70606e..693f6ccb 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -102,6 +102,7 @@ load( ":unit_component_test.bzl", "component_excludes_feature_req_docs_test", "component_provider_test", + "component_requirements_transitive_test", "component_sphinx_sources_test", "component_test_case_coverage_lock_test", "dependable_element_test_case_coverage_lock_check_action_test", @@ -870,13 +871,22 @@ unit( ) # Nested component containing unit2 +# Deliberately uses a DIFFERENT component_requirements target (:comp_req_rst) +# than the parent's own (:comp_req) below, so the two are genuinely +# distinguishable content-wise -- this makes ComponentInfo.requirements_transitive +# rollup observable: if the nested component's own requirements failed to +# propagate up, only :comp_req's .lobster file would show up at the parent, +# not :comp_req_rst's. See component_requirements_transitive_test. +# component( name = "test_nested_component", testonly = True, components = [":test_unit2"], requirements = [ ":comp_req", + ":comp_req_rst", ":feat_req", + ":feat_req_rst", ], tests = [], ) @@ -1316,6 +1326,15 @@ component_test_case_coverage_lock_test( target_under_test = ":test_component_with_test_case_coverage_lock", ) +# Verifies ComponentInfo.requirements_transitive rolls up both the parent's +# own component_requirements AND the nested component's (which use two +# distinct fixtures, :comp_req and :comp_req_rst -- see test/BUILD comments +# on test_nested_component/test_parent_component above). +component_requirements_transitive_test( + name = "component_requirements_transitive_test", + target_under_test = ":test_parent_component", +) + # Verifies the ComponentTestCaseCoverageLockCheck action is wired up end-to-end through # dependable_element (not just component() in isolation), and that # --allow-check-failures is only passed for maturity == "development". @@ -1521,7 +1540,10 @@ py_test( name = "test_aou_forwarding_to_lobster", size = "small", srcs = ["test_aou_forwarding_to_lobster.py"], - deps = ["@score_tooling//bazel/rules/rules_score:aou_forwarding_to_lobster"], + deps = [ + "@lobster//lobster/common", + "@score_tooling//bazel/rules/rules_score:aou_forwarding_to_lobster", + ], ) py_test( diff --git a/bazel/rules/rules_score/test/lobster_config_test.bzl b/bazel/rules/rules_score/test/lobster_config_test.bzl index c9cae8be..8a2ae1d3 100644 --- a/bazel/rules/rules_score/test/lobster_config_test.bzl +++ b/bazel/rules/rules_score/test/lobster_config_test.bzl @@ -150,3 +150,60 @@ def lobster_config_test_suite(name): trace_to = ["Failure Modes", "Control Measures"], ), ) + + # --- format_lobster_block: requires (OR-of-sources override) -------------- + # By default, when multiple different levels each `trace to:` the same + # target, LOBSTER requires ALL of them to independently cover every item + # (AND across sources). `requires` overrides this to "any one of these is + # sufficient" for a given target level -- see e.g. Received AoUs, which + # must be covered by EITHER Component Requirements OR Forwarded AoUs, not + # both at once. + + loadingtest.equals( + env, + "block_with_requires_or_group", + 'requirements "Received AoUs" {\n source: "aou.lobster";\n requires: "Component Requirements" or "Forwarded AoUs";\n}', + format_lobster_block( + "requirements", + "Received AoUs", + [_fake_file("aou.lobster")], + requires = [["Component Requirements", "Forwarded AoUs"]], + ), + ) + + loadingtest.equals( + env, + "block_with_requires_single_name", + 'requirements "Received AoUs" {\n source: "aou.lobster";\n requires: "Component Requirements";\n}', + format_lobster_block( + "requirements", + "Received AoUs", + [_fake_file("aou.lobster")], + requires = [["Component Requirements"]], + ), + ) + + loadingtest.equals( + env, + "block_with_requires_empty_group_omitted", + 'requirements "Received AoUs" {\n source: "aou.lobster";\n}', + format_lobster_block( + "requirements", + "Received AoUs", + [_fake_file("aou.lobster")], + requires = [[]], + ), + ) + + loadingtest.equals( + env, + "block_with_trace_to_and_requires_combined", + 'requirements "Component Requirements" {\n source: "comp.lobster";\n trace to: "Feature Requirements";\n requires: "Feature Requirements" or "Received AoUs";\n}', + format_lobster_block( + "requirements", + "Component Requirements", + [_fake_file("comp.lobster")], + trace_to = ["Feature Requirements"], + requires = [["Feature Requirements", "Received AoUs"]], + ), + ) 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 49daead2..324ad7d2 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 @@ -12,20 +12,33 @@ # ******************************************************************************* """Tests for aou_forwarding_to_lobster.""" -import json import tempfile import unittest import yaml +from lobster.common.items import Requirement, Tracing_Tag +from lobster.common.location import Void_Reference from aou_forwarding_to_lobster import ( - create_lobster_output, + build_forwarded_markers, filter_forwarded_aous, load_lobster_items, parse_forwarding_yaml, ) +def _req(tag: str, name: str) -> Requirement: + """Build a minimal Requirement item with the given 'req Pkg.Name[@ver]' tag.""" + namespace, rest = tag.split(" ", 1) + return Requirement( + tag=Tracing_Tag.from_text(namespace, rest), + location=Void_Reference(), + framework="TRLC", + kind="AoU", + name=name, + ) + + class TestParseForwardingYaml(unittest.TestCase): """Tests for parse_forwarding_yaml.""" @@ -79,31 +92,24 @@ def test_multiple_entries(self) -> None: class TestLoadLobsterItems(unittest.TestCase): """Tests for load_lobster_items.""" - def _write_lobster(self, items: list) -> str: - data = { - "schema": "lobster-req-trace", - "version": 3, - "generator": "test", - "data": items, - } + def _write_lobster(self, tags: list[str]) -> str: + from lobster.common.io import lobster_write + + items = [_req(tag, tag.split(" ", 1)[1].split("@")[0]) for tag in tags] f = tempfile.NamedTemporaryFile(mode="w", suffix=".lobster", delete=False) - json.dump(data, f) + lobster_write(f, Requirement, "test", items) f.close() return f.name def test_loads_items(self) -> None: - items = [ - {"tag": "req Pkg.AoU1", "name": "AoU1"}, - {"tag": "req Pkg.AoU2", "name": "AoU2"}, - ] - path = self._write_lobster(items) + path = self._write_lobster(["req Pkg.AoU1", "req Pkg.AoU2"]) loaded = load_lobster_items([path]) self.assertEqual(len(loaded), 2) - self.assertEqual(loaded[0]["tag"], "req Pkg.AoU1") + self.assertEqual(str(loaded[0].tag), "req Pkg.AoU1") def test_multiple_files(self) -> None: - path1 = self._write_lobster([{"tag": "req A.B", "name": "B"}]) - path2 = self._write_lobster([{"tag": "req C.D", "name": "D"}]) + path1 = self._write_lobster(["req A.B"]) + path2 = self._write_lobster(["req C.D"]) loaded = load_lobster_items([path1, path2]) self.assertEqual(len(loaded), 2) @@ -117,20 +123,17 @@ class TestFilterForwardedAous(unittest.TestCase): """Tests for filter_forwarded_aous.""" def test_filters_correctly(self) -> None: - items = [ - {"tag": "req Pkg.AoU1", "name": "AoU1"}, - {"tag": "req Pkg.AoU2", "name": "AoU2"}, - ] + items = [_req("req Pkg.AoU1", "AoU1"), _req("req Pkg.AoU2", "AoU2")] entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] filtered = filter_forwarded_aous(entries, items) self.assertEqual(len(filtered), 1) - self.assertEqual(filtered[0]["tag"], "req Pkg.AoU1") + self.assertEqual(str(filtered[0].tag), "req Pkg.AoU1") def test_multiple_filters(self) -> None: items = [ - {"tag": "req A.B", "name": "B"}, - {"tag": "req C.D", "name": "D"}, - {"tag": "req E.F", "name": "F"}, + _req("req A.B", "B"), + _req("req C.D", "D"), + _req("req E.F", "F"), ] entries = [ {"aou_id": "A.B", "justification": "r1"}, @@ -140,7 +143,7 @@ def test_multiple_filters(self) -> None: self.assertEqual(len(filtered), 2) def test_nonexistent_aou_id_raises(self) -> None: - items = [{"tag": "req Pkg.AoU1", "name": "AoU1"}] + items = [_req("req Pkg.AoU1", "AoU1")] entries = [{"aou_id": "NonExistent.Foo", "justification": "reason"}] with self.assertRaises(SystemExit): filter_forwarded_aous(entries, items) @@ -148,36 +151,108 @@ def test_nonexistent_aou_id_raises(self) -> None: def test_versioned_tag_matches_base_id(self) -> None: """lobster-trlc generates versioned tags like 'req Pkg.Name@1'.""" items = [ - {"tag": "req Pkg.AoU1@1", "name": "AoU1"}, - {"tag": "req Pkg.AoU2@3", "name": "AoU2"}, + _req("req Pkg.AoU1@1", "AoU1"), + _req("req Pkg.AoU2@3", "AoU2"), ] entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] filtered = filter_forwarded_aous(entries, items) self.assertEqual(len(filtered), 1) - self.assertEqual(filtered[0]["tag"], "req Pkg.AoU1@1") + self.assertEqual(str(filtered[0].tag), "req Pkg.AoU1@1") def test_versioned_tag_matches_full_id(self) -> None: """Full versioned ID should also work.""" - items = [{"tag": "req Pkg.AoU1@2", "name": "AoU1"}] + items = [_req("req Pkg.AoU1@2", "AoU1")] entries = [{"aou_id": "Pkg.AoU1@2", "justification": "reason"}] filtered = filter_forwarded_aous(entries, items) self.assertEqual(len(filtered), 1) + def test_ambiguous_base_id_requires_version(self) -> None: + """Two different versions sharing a base ID must not silently pick one.""" + items = [ + _req("req Pkg.AoU1@1", "AoU1"), + _req("req Pkg.AoU1@2", "AoU1"), + ] + entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] + with self.assertRaises(SystemExit): + filter_forwarded_aous(entries, items) -class TestCreateLobsterOutput(unittest.TestCase): - """Tests for create_lobster_output.""" + def test_ambiguous_base_id_still_resolves_with_explicit_version(self) -> None: + items = [ + _req("req Pkg.AoU1@1", "AoU1"), + _req("req Pkg.AoU1@2", "AoU1"), + ] + entries = [{"aou_id": "Pkg.AoU1@2", "justification": "reason"}] + filtered = filter_forwarded_aous(entries, items) + self.assertEqual(len(filtered), 1) + self.assertEqual(str(filtered[0].tag), "req Pkg.AoU1@2") - def test_wraps_items(self) -> None: - items = [{"tag": "req Foo.Bar", "name": "Bar"}] - output = create_lobster_output(items) - self.assertEqual(output["schema"], "lobster-req-trace") - self.assertEqual(output["version"], 3) - self.assertEqual(output["generator"], "aou_forwarding_to_lobster") - self.assertEqual(output["data"], items) + def test_duplicate_identical_item_is_not_ambiguous(self) -> None: + """The same AoU loaded twice (e.g. via two overlapping input files) is not a conflict.""" + items = [ + _req("req Pkg.AoU1@1", "AoU1"), + _req("req Pkg.AoU1@1", "AoU1"), + ] + entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] + filtered = filter_forwarded_aous(entries, items) + self.assertEqual(len(filtered), 1) + + +class TestBuildForwardedMarkers(unittest.TestCase): + """Tests for build_forwarded_markers.""" + + def test_builds_one_marker_per_entry(self) -> None: + items = [_req("req Pkg.AoU1@1", "AoU1"), _req("req Pkg.AoU2@1", "AoU2")] + entries = [ + {"aou_id": "Pkg.AoU1", "justification": "reason 1"}, + {"aou_id": "Pkg.AoU2", "justification": "reason 2"}, + ] + markers = build_forwarded_markers(entries, items, "aou_forwarding.yaml") + 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.""" + 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(ref) for ref in marker.unresolved_references], + ["req Pkg.AoU1@1"], + ) + + 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"}] + markers = build_forwarded_markers(entries, items, "aou_forwarding.yaml") + self.assertEqual(markers[0].text, "must be handled downstream") + + def test_marker_kind_and_framework(self) -> None: + items = [_req("req Pkg.AoU1@1", "AoU1")] + entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] + markers = build_forwarded_markers(entries, items, "aou_forwarding.yaml") + self.assertEqual(markers[0].kind, "ForwardedAoU") + self.assertEqual(markers[0].framework, "AoUForwarding") + + def test_marker_location_uses_yaml_path(self) -> None: + items = [_req("req Pkg.AoU1@1", "AoU1")] + entries = [{"aou_id": "Pkg.AoU1", "justification": "reason"}] + markers = build_forwarded_markers(entries, items, "some/path/aou_forwarding.yaml") + self.assertEqual(markers[0].location.filename, "some/path/aou_forwarding.yaml") + + def test_nonexistent_aou_id_raises(self) -> None: + items = [_req("req Pkg.AoU1@1", "AoU1")] + entries = [{"aou_id": "NonExistent.Foo", "justification": "reason"}] + with self.assertRaises(SystemExit): + build_forwarded_markers(entries, items, "aou_forwarding.yaml") - def test_empty_items(self) -> None: - output = create_lobster_output([]) - self.assertEqual(output["data"], []) + def test_empty_entries_produces_no_markers(self) -> None: + items = [_req("req Pkg.AoU1@1", "AoU1")] + markers = build_forwarded_markers([], items, "aou_forwarding.yaml") + self.assertEqual(markers, []) if __name__ == "__main__": diff --git a/bazel/rules/rules_score/test/unit_component_test.bzl b/bazel/rules/rules_score/test/unit_component_test.bzl index 7ad2b46a..0e49d4e0 100644 --- a/bazel/rules/rules_score/test/unit_component_test.bzl +++ b/bazel/rules/rules_score/test/unit_component_test.bzl @@ -132,6 +132,39 @@ def _component_provider_test_impl(ctx): component_provider_test = analysistest.make(_component_provider_test_impl) +def _component_requirements_transitive_test_impl(ctx): + """Test that ComponentInfo.requirements_transitive rolls up nested components. + + :test_parent_component nests :test_nested_component, and the two use + DIFFERENT component_requirements targets (:comp_req vs. :comp_req_rst, + see test/BUILD). Both must be present in the parent's + requirements_transitive, or the nested component's own requirements + silently stopped being rolled up (and thus stopped being covered by any + CI-visible traceability check at the dependable_element level). + """ + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + comp_info = target_under_test[ComponentInfo] + basenames = [f.basename for f in comp_info.requirements_transitive.to_list()] + + asserts.true( + env, + "comp_req.lobster" in basenames, + "Parent component's own requirements (:comp_req) missing from " + + "requirements_transitive; got: %s" % basenames, + ) + asserts.true( + env, + "comp_req_rst.lobster" in basenames, + "Nested component's requirements (:comp_req_rst) did not roll up into " + + "the parent's requirements_transitive; got: %s" % basenames, + ) + + return analysistest.end(env) + +component_requirements_transitive_test = analysistest.make(_component_requirements_transitive_test_impl) + def _component_sphinx_sources_test_impl(ctx): """Test that component rule provides SphinxSourcesInfo.""" env = analysistest.begin(ctx) @@ -306,6 +339,7 @@ def unit_component_test_suite(name): ":component_sphinx_sources_test", ":component_excludes_feature_req_docs_test", ":component_test_case_coverage_lock_test", + ":component_requirements_transitive_test", ":test_case_coverage_lock_check_action_release_test", ":test_case_coverage_lock_check_action_development_test", ], 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 3a2d44ce..5d81cc62 100644 --- a/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl +++ b/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl @@ -70,14 +70,22 @@ tuple FeatReqId { version Integer } +type ControlMeasure "A design or operational measure that detects, prevents, or limits a failure mode." extends RequirementSafety { + mitigates "Reference to the FailureMode or safety concern that this requirement mitigates." + optional String +} + +type AoU "Assumption of Use — a safety-relevant condition that a user must fulfil." extends ControlMeasure { +} + tuple CompReqSourceId { - item [FeatReq, AssumedSystemReq] + item [FeatReq, AssumedSystemReq, AoU] separator @ version Integer } type CompReq "Component-level requirement allocated to a specific software component." extends RequirementSafety { - derived_from "Versioned references to the FeatReq or AssumedSystemReq items this component requirement is derived from. Omit only for component-internal requirements with no feature-level parent." + 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." CompReqSourceId[1 .. *] } @@ -154,11 +162,6 @@ type FailureMode "A potential failure of a system function, described using one optional String } -type ControlMeasure "A design or operational measure that detects, prevents, or limits a failure mode." extends RequirementSafety { - mitigates "Reference to the FailureMode or safety concern that this requirement mitigates." - optional String -} - type PreventiveMeasure "A measure that prevents a failure mode from occurring in the first place." { } @@ -167,9 +170,6 @@ type Mitigation "A measure that reduces the severity or probability of a failure optional String } -type AoU "Assumption of Use — a safety-relevant condition that a user must fulfil." extends ControlMeasure { -} - tuple Measure { item [ControlMeasure, PreventiveMeasure, Mitigation] separator @