diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index 07e4fc2d..2e21bb27 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -39,6 +39,22 @@ compile_pip_requirements( ], ) +# RST-to-TRLC converter: converts RST requirement directives to TRLC format +py_library( + name = "rst_to_trlc_lib", + srcs = ["src/rst_to_trlc.py"], + imports = ["src"], + visibility = ["//visibility:public"], +) + +py_binary( + name = "rst_to_trlc", + srcs = ["src/rst_to_trlc.py"], + imports = ["src"], + main = "src/rst_to_trlc.py", + visibility = ["//visibility:public"], +) + # Arch-to-reqs-from-lobster tool: extracts requirements from component requirement .lobster files # and generates an architecture.lobster item representing the component's allocation py_binary( diff --git a/bazel/rules/rules_score/lobster/config/BUILD b/bazel/rules/rules_score/lobster/config/BUILD index 53d9df85..8efc55e0 100644 --- a/bazel/rules/rules_score/lobster/config/BUILD +++ b/bazel/rules/rules_score/lobster/config/BUILD @@ -26,6 +26,12 @@ filegroup( visibility = ["//visibility:public"], ) +filegroup( + name = "assumed_system_requirement", + srcs = ["lobster_assumed_system_req.yaml"], + visibility = ["//visibility:public"], +) + filegroup( name = "failuremodes_config", srcs = ["lobster_failuremodes.yaml"], diff --git a/bazel/rules/rules_score/lobster/config/lobster_assumed_system_req.yaml b/bazel/rules/rules_score/lobster/config/lobster_assumed_system_req.yaml new file mode 100644 index 00000000..d7d42b13 --- /dev/null +++ b/bazel/rules/rules_score/lobster/config/lobster_assumed_system_req.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 +# ******************************************************************************* +inputs: + - . +conversion-rules: + - package: ScoreReq + record-type: AssumedSystemReq + namespace: req + version-field: version + description-fields: description diff --git a/bazel/rules/rules_score/private/assumptions_of_use.bzl b/bazel/rules/rules_score/private/assumptions_of_use.bzl index 97f6c52b..ee429707 100644 --- a/bazel/rules/rules_score/private/assumptions_of_use.bzl +++ b/bazel/rules/rules_score/private/assumptions_of_use.bzl @@ -19,7 +19,9 @@ following S-CORE process guidelines. Assumptions of Use define the safety-releva operating conditions and constraints for a Safety Element out of Context (SEooC). """ +load("@trlc//:trlc.bzl", "TrlcProviderInfo", "trlc_requirements_test") load("//bazel/rules/rules_score:providers.bzl", "AssumptionsOfUseInfo", "ComponentRequirementsInfo", "FeatureRequirementsInfo", "SphinxSourcesInfo") +load("//bazel/rules/rules_score/private:rst_to_trlc.bzl", "rst_srcs_to_trlc") # ============================================================================ # Private Rule Implementation @@ -79,9 +81,9 @@ _assumptions_of_use = rule( doc = "Collects Assumptions of Use documents with traceability to feature requirements", attrs = { "srcs": attr.label_list( - allow_files = [".rst", ".md", ".trlc"], + providers = [TrlcProviderInfo], mandatory = True, - doc = "Source files containing Assumptions of Use specifications", + doc = "trlc_requirements targets containing Assumptions of Use specifications", ), "requirements": attr.label_list( providers = [[FeatureRequirementsInfo], [ComponentRequirementsInfo]], @@ -99,6 +101,7 @@ def assumptions_of_use( name, srcs, requirements = [], + ref_package = None, visibility = None): """Define Assumptions of Use following S-CORE process guidelines. @@ -110,29 +113,49 @@ def assumptions_of_use( Args: name: The name of the assumptions of use target. Used as the base name for all generated targets. - srcs: List of labels to .rst, .md, or .trlc files containing the + srcs: List of labels to trlc_requirements targets containing the Assumptions of Use specifications as defined in the S-CORE - process. + process. RST files containing ``aou_req`` directives are also + accepted and will be converted to TRLC automatically. requirements: Optional list of labels to feature or component requirements targets that these Assumptions of Use trace to. Establishes traceability as defined in the S-CORE process. + ref_package: Optional TRLC package prefix used for ``derived_from`` + cross-references when converting RST sources. visibility: Bazel visibility specification for the generated targets. Generated Targets: : Main assumptions of use target providing AssumptionsOfUseInfo + _test: TRLC validation test for the assumptions of use sources - Example: + Example using trlc_requirements targets: ```starlark assumptions_of_use( name = "my_assumptions_of_use", - srcs = ["assumptions_of_use.rst"], + srcs = [":my_aous_trlc"], + requirements = [":my_feature_requirements"], + ) + ``` + + Example using RST sources directly: + ```starlark + assumptions_of_use( + name = "my_assumptions_of_use", + srcs = ["docs/assumptions_of_use.rst"], requirements = [":my_feature_requirements"], ) ``` """ + trlc_srcs = rst_srcs_to_trlc(name, srcs, ref_package = ref_package or "") + _assumptions_of_use( name = name, - srcs = srcs, + srcs = trlc_srcs, requirements = requirements, visibility = visibility, ) + trlc_requirements_test( + name = name + "_test", + reqs = trlc_srcs, + visibility = visibility, + ) diff --git a/bazel/rules/rules_score/private/requirements.bzl b/bazel/rules/rules_score/private/requirements.bzl index 420ba8cd..a4fe66b6 100644 --- a/bazel/rules/rules_score/private/requirements.bzl +++ b/bazel/rules/rules_score/private/requirements.bzl @@ -19,8 +19,9 @@ This module provides macros and rules for defining requirements at any level """ load("@lobster//:lobster.bzl", "subrule_lobster_trlc") -load("@trlc//:trlc.bzl", "TrlcProviderInfo", "trlc_requirements_test") -load("//bazel/rules/rules_score:providers.bzl", "ComponentRequirementsInfo", "FeatureRequirementsInfo", "SphinxSourcesInfo") +load("@trlc//:trlc.bzl", "TrlcProviderInfo", "trlc_requirements", "trlc_requirements_test") +load("//bazel/rules/rules_score:providers.bzl", "AssumedSystemRequirementsInfo", "ComponentRequirementsInfo", "FeatureRequirementsInfo", "SphinxSourcesInfo") +load("//bazel/rules/rules_score/private:rst_to_trlc.bzl", "rst_srcs_to_trlc") # ============================================================================ # Private Rule Implementation @@ -68,11 +69,16 @@ def _requirements_impl(ctx): srcs = depset([lobster_trlc_file]), name = ctx.label.name, ) - else: + elif ctx.attr.req_kind == "component": req_provider = ComponentRequirementsInfo( srcs = depset([lobster_trlc_file]), name = ctx.label.name, ) + else: # assumed_system + req_provider = AssumedSystemRequirementsInfo( + srcs = depset([lobster_trlc_file]), + name = ctx.label.name, + ) return [ DefaultInfo(files = all_srcs), @@ -102,9 +108,9 @@ _requirements = rule( doc = "Lobster YAML configuration file for traceability extraction", ), "req_kind": attr.string( - values = ["feature", "component"], + values = ["feature", "component", "assumed_system"], mandatory = True, - doc = "Kind of requirements: 'feature' or 'component'", + doc = "Kind of requirements: 'feature', 'component', or 'assumed_system'.", ), "_renderer": attr.label( default = Label("@trlc//tools/trlc_rst:trlc_rst"), @@ -119,53 +125,123 @@ _requirements = rule( # ============================================================================ # Public Macros # ============================================================================ +def _create_trlc_aliases(name, srcs, visibility): + """Expose stable public aliases for generated trlc_requirements targets. -def feature_requirements( - name, - srcs, - visibility = None): - """Define feature requirements following S-CORE process guidelines. + For each RST file in *srcs*, a named alias is created so that downstream + requirement macros can reference the generated trlc_requirements target via + ``deps`` for cross-package TRLC validation without knowing internal names. + When a single RST file is given the alias is ``_trlc``; for multiple + RST files the per-source index is appended (``_trlc_0``, …). Args: - name: The name of the target. - srcs: List of labels to trlc_requirements targets providing TrlcProviderInfo. + name: Base name used by the enclosing macro (same as passed to + rst_srcs_to_trlc). + srcs: Original srcs list passed to the enclosing macro. + visibility: Bazel visibility to apply to the generated aliases. + """ + rst_count = len([s for s in srcs if s.endswith(".rst")]) + rst_index = 0 + for i, src in enumerate(srcs): + if src.endswith(".rst"): + alias_name = name + "_trlc" if rst_count == 1 else "{}_trlc_{}".format(name, rst_index) + native.alias( + name = alias_name, + actual = ":_{}_trlc_{}".format(name, i), + visibility = visibility, + ) + rst_index += 1 + +def _score_requirements(name, srcs, deps, ref_package, visibility, req_kind): + """Shared implementation for feature_requirements and component_requirements. + + Args: + name: Target name. + srcs: Mixed list of trlc_requirements labels or RST file paths. + deps: trlc_requirements labels used as parsing dependencies for RST files. + ref_package: TRLC package prefix for derived_from cross-references. visibility: Bazel visibility specification. + req_kind: Either "feature" or "component". """ + trlc_srcs = rst_srcs_to_trlc(name, srcs, deps = deps, ref_package = ref_package or "") _requirements( name = name, - srcs = srcs, - lobster_config = Label("//bazel/rules/rules_score/lobster/config:feature_requirement"), - req_kind = "feature", + srcs = trlc_srcs, + lobster_config = Label("//bazel/rules/rules_score/lobster/config:{}_requirement".format(req_kind)), + req_kind = req_kind, visibility = visibility, ) - trlc_requirements_test( name = name + "_test", - reqs = srcs, + reqs = trlc_srcs, visibility = visibility, ) + _create_trlc_aliases(name, srcs, visibility) + +def assumed_system_requirements( + name, + srcs, + deps = [], + ref_package = None, + visibility = None): + """Define Assumed System Requirements following S-CORE process guidelines. + + Creates an assumed_system_requirements target (providing AssumedSystemRequirementsInfo + and SphinxSourcesInfo) and a validation test target named *name*_test. + + Args: + name: The name of the target. + srcs: List of trlc_requirements labels (providing TrlcProviderInfo) + or RST file paths containing ``asr_req`` directives. + RST files are converted to TRLC automatically. + deps: Optional list of trlc_requirements labels to include as + parsing dependencies. Only used when RST files are present in *srcs*. + ref_package: TRLC package prefix for derived_from cross-references + when converting RST sources. + visibility: Bazel visibility specification. + """ + _score_requirements(name, srcs, deps, ref_package, visibility, "assumed_system") + +def feature_requirements( + name, + srcs, + deps = [], + ref_package = None, + visibility = None): + """Define feature requirements following S-CORE process guidelines. + + Args: + name: The name of the target. + srcs: List of trlc_requirements labels (providing TrlcProviderInfo) + or RST file paths containing ``feat_req`` directives. + RST files are converted to TRLC automatically. + deps: Optional list of trlc_requirements labels to include as + parsing dependencies (e.g. the assumed system requirements + target). Only used when RST files are present in *srcs*. + ref_package: TRLC package prefix for derived_from cross-references + when converting RST sources. + visibility: Bazel visibility specification. + """ + _score_requirements(name, srcs, deps, ref_package, visibility, "feature") def component_requirements( name, srcs = [], + deps = [], + ref_package = None, visibility = None): """Define component requirements following S-CORE process guidelines. Args: name: The name of the target. - srcs: List of labels to trlc_requirements targets providing TrlcProviderInfo. + srcs: List of trlc_requirements labels (providing TrlcProviderInfo) + or RST file paths containing ``comp_req`` directives. + RST files are converted to TRLC automatically. + deps: Optional list of trlc_requirements labels to include as + parsing dependencies (e.g. assumed system or feature requirement + targets). Only used when RST files are present in *srcs*. + ref_package: TRLC package prefix for derived_from cross-references + when converting RST sources. visibility: Bazel visibility specification. """ - _requirements( - name = name, - srcs = srcs, - lobster_config = Label("//bazel/rules/rules_score/lobster/config:component_requirement"), - req_kind = "component", - visibility = visibility, - ) - - trlc_requirements_test( - name = name + "_test", - reqs = srcs, - visibility = visibility, - ) + _score_requirements(name, srcs, deps, ref_package, visibility, "component") diff --git a/bazel/rules/rules_score/private/rst_to_trlc.bzl b/bazel/rules/rules_score/private/rst_to_trlc.bzl new file mode 100644 index 00000000..8948462f --- /dev/null +++ b/bazel/rules/rules_score/private/rst_to_trlc.bzl @@ -0,0 +1,114 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Bazel rule and helper macro for converting RST requirement directives to TRLC files.""" + +load("@trlc//:trlc.bzl", "trlc_requirements") + +def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = ""): + """Convert any .rst entries in srcs to trlc_requirements targets. + + For each .rst entry a pair of intermediate targets is generated: + - A rst_to_trlc conversion target (produces the .trlc file) + - A trlc_requirements target (provides TrlcProviderInfo with the score model spec) + + Non-.rst entries are passed through unchanged (assumed to already be + trlc_requirements labels providing TrlcProviderInfo). + + Args: + name: Base name of the enclosing macro target, used to derive + unique names for the generated intermediaries. + srcs: Mixed list of .rst file paths and/or trlc_requirements labels. + deps: trlc_requirements labels to include as deps when wrapping + generated .trlc files (e.g. parent requirement packages). + ref_package: TRLC package prefix for derived_from cross-references + written into the generated .trlc content. + + Returns: + List of srcs where .rst entries are replaced by generated trlc labels. + """ + result = [] + for i, src in enumerate(srcs): + if src.endswith(".rst"): + gen_name = "_{}_rst_gen_{}".format(name, i) + trlc_name = "_{}_trlc_{}".format(name, i) + rst_to_trlc( + name = gen_name, + srcs = [src], + ref_package = ref_package, + ) + trlc_requirements( + name = trlc_name, + srcs = [":" + gen_name], + spec = [Label("//bazel/rules/rules_score/trlc/config:score_requirements_model")], + deps = deps, + ) + result.append(":" + trlc_name) + else: + result.append(src) + return result + +def _rst_to_trlc_impl(ctx): + """Convert each .rst source file to a .trlc file via the Python converter.""" + outs = [] + for src in ctx.files.srcs: + out = ctx.actions.declare_file(src.basename[:-4] + ".trlc", sibling = src) + outs.append(out) + + args = ctx.actions.args() + args.add(src.path) + args.add("--output-dir") + args.add(out.dirname) + if ctx.attr.ref_package: + args.add("--ref-package") + args.add(ctx.attr.ref_package) + if ctx.attr.package: + args.add("--package") + args.add(ctx.attr.package) + + ctx.actions.run( + executable = ctx.executable._converter, + inputs = [src], + outputs = [out], + arguments = [args], + mnemonic = "RstToTrlc", + progress_message = "Converting %s to TRLC" % src.short_path, + ) + + return [DefaultInfo(files = depset(outs))] + +rst_to_trlc = rule( + implementation = _rst_to_trlc_impl, + doc = "Converts RST requirement directives to TRLC source files.", + attrs = { + "srcs": attr.label_list( + allow_files = [".rst"], + mandatory = True, + doc = "RST files containing supported requirement directives.", + ), + "_converter": attr.label( + default = Label("//bazel/rules/rules_score:rst_to_trlc"), + executable = True, + allow_files = True, + cfg = "exec", + ), + "ref_package": attr.string( + default = "", + doc = "TRLC package prefix used for derived_from cross-references.", + ), + "package": attr.string( + default = "", + doc = "Optional TRLC package name override; defaults to the input file stem.", + ), + }, +) diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl index f0e63f08..6773dd5b 100644 --- a/bazel/rules/rules_score/providers.bzl +++ b/bazel/rules/rules_score/providers.bzl @@ -85,6 +85,14 @@ ComponentRequirementsInfo = provider( }, ) +AssumedSystemRequirementsInfo = provider( + doc = "Provider for assumed system requirements artifacts.", + fields = { + "srcs": "Depset of .lobster traceability files generated from TRLC requirement sources.", + "name": "Name of the requirements target.", + }, +) + AnalysisInfo = provider( doc = "Provider for safety analysis traceability artifacts (lobster files).", fields = { diff --git a/bazel/rules/rules_score/rules_score.bzl b/bazel/rules/rules_score/rules_score.bzl index be2fe9c3..14e75afa 100644 --- a/bazel/rules/rules_score/rules_score.bzl +++ b/bazel/rules/rules_score/rules_score.bzl @@ -43,6 +43,7 @@ load( ) load( "//bazel/rules/rules_score/private:requirements.bzl", + _assumed_system_requirements = "assumed_system_requirements", _component_requirements = "component_requirements", _feature_requirements = "feature_requirements", ) @@ -61,6 +62,7 @@ load( architectural_design = _architectural_design assumptions_of_use = _assumptions_of_use +assumed_system_requirements = _assumed_system_requirements component_requirements = _component_requirements dependability_analysis = _dependability_analysis feature_requirements = _feature_requirements diff --git a/bazel/rules/rules_score/src/rst_to_trlc.py b/bazel/rules/rules_score/src/rst_to_trlc.py new file mode 100644 index 00000000..cc7fdb5a --- /dev/null +++ b/bazel/rules/rules_score/src/rst_to_trlc.py @@ -0,0 +1,250 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""RST requirement directive to TRLC converter.""" + +import argparse +import re +import sys +from pathlib import Path +from typing import Any + +# Maps RST directive names to TRLC types in the S-CORE requirements model. +# Only directives that correspond to a concrete TRLC type in score_requirements_model.rsl +# are listed here. All other directives in RST source files are silently skipped. +DIRECTIVE_TO_TRLC: dict[str, str] = { + # Assumed System Requirements (root of the S-CORE traceability chain) + "assumed_system_req": "ScoreReq.AssumedSystemReq", + # Feature Requirements + "feat_req": "ScoreReq.FeatReq", + # Component Requirements + "comp_req": "ScoreReq.CompReq", + # Assumptions of Use + "aou_req": "ScoreReq.AoU", +} + +# Maps RST :safety: field values to ScoreReq.Asil enum literals. +# Values are transferred directly without any rounding or promotion logic. +SAFETY_MAP: dict[str, str] = { + "QM": "ScoreReq.Asil.QM", + "ASIL_A": "ScoreReq.Asil.A", + "ASIL_B": "ScoreReq.Asil.B", + "ASIL_C": "ScoreReq.Asil.C", + "ASIL_D": "ScoreReq.Asil.D", +} + +# RST fields that carry cross-package requirement references and are mapped to +# the TRLC ``derived_from`` attribute. Only ``satisfies`` and ``derived_from`` +# are used in the S-CORE process templates; all other relationship keywords +# (e.g. ``fulfils``, ``mitigates``) are not part of the TRLC model and are +# therefore excluded from this list. +_REF_FIELDS = ("satisfies", "derived_from") + +# Explicit whitelist of RST field names that are transferred to TRLC. +# Every RST field not in this set is silently ignored during conversion. +# This keeps generated TRLC files independent of Sphinx-needs-only attributes +# (e.g. ``reqtype``, ``security``, ``valid_from``, ``belongs_to``, ``tags``). +_ALLOWED_RST_ATTRS: frozenset[str] = frozenset( + { + "id", # → TRLC record name (not written as a field) + "safety", # → safety + "satisfies", # → derived_from cross-reference + "derived_from", # → derived_from cross-reference + "rationale", # → rationale (mandatory for AssumedSystemReq) + "version", # → version + } +) + +# TRLC types that require a rationale field. +_ASSUMED_SYSTEM_REQ_TYPES = {"ScoreReq.AssumedSystemReq"} + +_DEFAULT_SAFETY = "QM" +_DEFAULT_VERSION = "1" +_DEFAULT_REF_PACKAGE = "TODO_PACKAGE" +_IMPORTS = ["ScoreReq"] + +_RE_MARKUP = re.compile(r"\*\*?(.*?)\*\*?") +_RE_DIRECTIVE = re.compile(r"^\.\.\s+([\w]+)::\s*(.*)") +_RE_FIELD = re.compile(r"^\s+:([\w]+):\s*(.*)") # noqa: E501 + +_TRLC_HEADER = """\ +/******************************************************************************** + * 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 + ********************************************************************************/""" + + +def _collect_fields(lines: list[str], i: int) -> tuple[dict[str, str], int]: + """Read RST field list starting at line i. Return (fields dict, next line).""" + fields: dict[str, str] = {} + while i < len(lines): + m = _RE_FIELD.match(lines[i]) + if m: + fields[m.group(1)] = m.group(2).strip() + i += 1 + elif not lines[i].strip(): + return fields, i + 1 + else: + return fields, i + return fields, i + + +def _collect_body(lines: list[str], i: int) -> tuple[str, int]: + """Read indented body text starting at line i. Return (body text, next line).""" + body_parts: list[str] = [] + while i < len(lines): + line = lines[i] + if line and not line[0].isspace(): + break + if not line.strip(): + nxt = next( + (lines[j] for j in range(i + 1, len(lines)) if lines[j].strip()), "" + ) + if not nxt or not nxt[0].isspace(): + return " ".join(p for p in body_parts if p), i + 1 + body_parts.append(line.strip()) + i += 1 + return " ".join(p for p in body_parts if p), i + + +def _escape(text: str) -> str: + """Escape a string for use inside TRLC double-quoted literals.""" + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def _collect_refs(fields: dict[str, str]) -> list[str]: + """Extract all cross-reference IDs from relationship fields.""" + return [ + r.strip() + for k in _REF_FIELDS + if k in fields + for r in fields[k].split(",") + if r.strip() + ] + + +def parse_directives(content: str) -> list[dict[str, Any]]: + """Parse supported requirement directives from RST content.""" + results: list[dict[str, Any]] = [] + lines = content.splitlines() + i = 0 + while i < len(lines): + m = _RE_DIRECTIVE.match(lines[i]) + if not m or m.group(1) not in DIRECTIVE_TO_TRLC: + i += 1 + continue + + directive, title = m.group(1), m.group(2).strip() + i += 1 + + fields, i = _collect_fields(lines, i) + raw_body, i = _collect_body(lines, i) + body = _RE_MARKUP.sub(r"\1", raw_body).strip() + + results.append( + {"directive": directive, "title": title, "fields": fields, "body": body} + ) + return results + + +def render_trlc( + directives: list[dict[str, Any]], package: str, ref_package: str +) -> str: + """Render parsed directives into TRLC file content.""" + has_refs = any(_collect_refs(item["fields"]) for item in directives) + imports = list(_IMPORTS) + if ( + has_refs + and ref_package + and ref_package != _DEFAULT_REF_PACKAGE + and ref_package not in imports + ): + imports.append(ref_package) + import_lines = [f"import {name}" for name in imports] + lines_out = [_TRLC_HEADER, f"package {package}", "", *import_lines, ""] + + for item in directives: + fields = item["fields"] + trlc_type = DIRECTIVE_TO_TRLC[item["directive"]] + name = fields.get("id") or re.sub(r"\W+", "_", item["title"]).strip("_") + safety = SAFETY_MAP.get( + fields.get("safety", _DEFAULT_SAFETY).upper(), + SAFETY_MAP[_DEFAULT_SAFETY], + ) + desc = _escape(item["body"] or item["title"]) + + lines_out.append(f"{trlc_type} {name} {{") + lines_out.append(f' description = "{desc}"') + lines_out.append(f" safety = {safety}") + + refs = _collect_refs(fields) + if refs: + ref_list = ", ".join(f"{ref_package}.{r}@1" for r in refs) + lines_out.append(f" derived_from = [{ref_list}]") + + if trlc_type in _ASSUMED_SYSTEM_REQ_TYPES: + rationale = fields.get("rationale", "TODO: add rationale") + lines_out.append(f' rationale = "{_escape(rationale)}"') + + lines_out.append(f" version = {fields.get('version', _DEFAULT_VERSION)}") + lines_out.append("}\n") + + return "\n".join(lines_out) + + +def convert( + input_path: Path, + output_path: Path, + *, + package: str | None = None, + ref_package: str | None = None, +) -> int: + """Convert one RST file to TRLC. Returns number of records written.""" + pkg = package or "".join( + w.capitalize() for w in re.split(r"[_\-\s]+", input_path.stem) + ) + directives = parse_directives(input_path.read_text(encoding="utf-8")) + if not directives: + print( + f"WARNING: no supported requirement directives found in {input_path}", + file=sys.stderr, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + render_trlc(directives, pkg, ref_package or _DEFAULT_REF_PACKAGE), + encoding="utf-8", + ) + return len(directives) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="RST to TRLC converter") + p.add_argument("input_file", type=Path) + p.add_argument("--output-dir", type=Path, required=True) + p.add_argument("--package", default=None) + p.add_argument("--ref-package", default=None) + args = p.parse_args() + if not args.input_file.exists(): + sys.exit(f"ERROR: file not found: {args.input_file}") + output_file = args.output_dir / (args.input_file.stem + ".trlc") + record_count = convert( + args.input_file, output_file, package=args.package, ref_package=args.ref_package + ) + print(f" {args.input_file} -> {output_file} ({record_count} record(s))") diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 867f071d..bf9f99e0 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -14,6 +14,7 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") load( "@score_tooling//bazel/rules/rules_score:rules_score.bzl", "architectural_design", + "assumed_system_requirements", "assumptions_of_use", "component", "component_requirements", @@ -38,6 +39,19 @@ load( "providers_test", "sphinx_module_test_suite", ) +load( + ":requirements_rst_test.bzl", + "aous_rst_provider_test", + "aous_rst_sphinx_test", + "asr_rst_output_test", + "asr_rst_provider_test", + "asr_rst_sphinx_test", + "comp_req_rst_provider_test", + "comp_req_rst_sphinx_test", + "feat_req_rst_provider_test", + "feat_req_rst_sphinx_test", + "requirements_rst_test_suite", +) load( ":score_module_providers_test.bzl", "config_auto_generation_test", @@ -593,6 +607,84 @@ component_sphinx_sources_test( # Unit, Component, and Dependable Element test suite unit_component_test_suite(name = "unit_component_tests") +# ============================================================================ +# RST-based Requirements Tests +# ============================================================================ + +# Fixture: assumed_system_requirements from RST +assumed_system_requirements( + name = "asr_rst", + srcs = ["fixtures/rst_requirements/assumed_system_requirements.rst"], +) + +# Fixture: feature_requirements from RST (depends on asr_rst for derived_from resolution) +feature_requirements( + name = "feat_req_rst", + srcs = ["fixtures/rst_requirements/feature_requirements.rst"], + ref_package = "AssumedSystemRequirements", + deps = [":asr_rst_trlc"], +) + +# Fixture: component_requirements from RST +component_requirements( + name = "comp_req_rst", + srcs = ["fixtures/rst_requirements/component_requirements.rst"], +) + +# Fixture: assumptions_of_use from RST +assumptions_of_use( + name = "aous_rst", + srcs = ["fixtures/rst_requirements/assumptions_of_use.rst"], + requirements = [":feat_req_rst"], +) + +asr_rst_output_test( + name = "asr_rst_output_test", + target_under_test = ":asr_rst", +) + +asr_rst_provider_test( + name = "asr_rst_provider_test", + target_under_test = ":asr_rst", +) + +asr_rst_sphinx_test( + name = "asr_rst_sphinx_test", + target_under_test = ":asr_rst", +) + +feat_req_rst_provider_test( + name = "feat_req_rst_provider_test", + target_under_test = ":feat_req_rst", +) + +feat_req_rst_sphinx_test( + name = "feat_req_rst_sphinx_test", + target_under_test = ":feat_req_rst", +) + +comp_req_rst_provider_test( + name = "comp_req_rst_provider_test", + target_under_test = ":comp_req_rst", +) + +comp_req_rst_sphinx_test( + name = "comp_req_rst_sphinx_test", + target_under_test = ":comp_req_rst", +) + +aous_rst_provider_test( + name = "aous_rst_provider_test", + target_under_test = ":aous_rst", +) + +aous_rst_sphinx_test( + name = "aous_rst_sphinx_test", + target_under_test = ":aous_rst", +) + +requirements_rst_test_suite(name = "requirements_rst_tests") + # ============================================================================ # Combined Test Suite # ============================================================================ @@ -623,12 +715,22 @@ py_test( deps = ["//bazel/rules/rules_score:safety_analysis_tools"], ) +py_test( + name = "test_rst_to_trlc", + size = "small", + srcs = ["rst_to_trlc_test.py"], + main = "rst_to_trlc_test.py", + deps = ["//bazel/rules/rules_score:rst_to_trlc_lib"], +) + # Combined test suite for all tests test_suite( name = "all_tests", tests = [ + ":requirements_rst_tests", ":seooc_tests", ":sphinx_module_tests", + ":test_rst_to_trlc", ":test_safety_analysis_tools", ":unit_component_tests", ], diff --git a/bazel/rules/rules_score/test/fixtures/rst_requirements/assumed_system_requirements.rst b/bazel/rules/rules_score/test/fixtures/rst_requirements/assumed_system_requirements.rst new file mode 100644 index 00000000..54203d55 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_requirements/assumed_system_requirements.rst @@ -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 + # ******************************************************************************* + +Assumed System Requirements (RST) +================================== + +.. assumed_system_req:: Minimal Interface + :id: asr_req__rst_test__001 + :safety: ASIL_B + :status: valid + + The system shall provide a minimal interface for RST-based test fixture validation. diff --git a/bazel/rules/rules_score/test/fixtures/rst_requirements/assumptions_of_use.rst b/bazel/rules/rules_score/test/fixtures/rst_requirements/assumptions_of_use.rst new file mode 100644 index 00000000..d15b706f --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_requirements/assumptions_of_use.rst @@ -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 + # ******************************************************************************* + +Assumptions of Use (RST) +========================== + +.. aou_req:: Operating Conditions + :id: aou_req__rst_test__001 + :safety: ASIL_B + :status: valid + + The SEooC shall operate within the conditions defined in the RST-based test fixture. diff --git a/bazel/rules/rules_score/test/fixtures/rst_requirements/component_requirements.rst b/bazel/rules/rules_score/test/fixtures/rst_requirements/component_requirements.rst new file mode 100644 index 00000000..c3c986eb --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_requirements/component_requirements.rst @@ -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 + # ******************************************************************************* + +Component Requirements (RST) +============================== + +.. comp_req:: Mock Return Values + :id: comp_req__rst_test__001 + :safety: ASIL_B + :status: valid + + The mock library shall provide mock_function_1 returning 42 for RST-based fixture validation. diff --git a/bazel/rules/rules_score/test/fixtures/rst_requirements/feature_requirements.rst b/bazel/rules/rules_score/test/fixtures/rst_requirements/feature_requirements.rst new file mode 100644 index 00000000..b5edeb43 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_requirements/feature_requirements.rst @@ -0,0 +1,24 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Feature Requirements (RST) +============================ + +.. feat_req:: Mock Interface + :id: feat_req__rst_test__001 + :safety: ASIL_B + :status: valid + :derived_from: asr_req__rst_test__001 + + The test component shall provide a mock function interface for RST-based unit testing. diff --git a/bazel/rules/rules_score/test/requirements_rst_test.bzl b/bazel/rules/rules_score/test/requirements_rst_test.bzl new file mode 100644 index 00000000..5e6c8fde --- /dev/null +++ b/bazel/rules/rules_score/test/requirements_rst_test.bzl @@ -0,0 +1,259 @@ +# ******************************************************************************* +# 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 RST-based requirement macros: + - assumed_system_requirements(srcs=[".rst"]) + - feature_requirements(srcs=[".rst"]) + - component_requirements(srcs=[".rst"]) + - assumptions_of_use(srcs=[".rst"]) + +Each test verifies that the macro correctly exposes its provider when the +input is an RST file rather than a pre-built trlc_requirements label. +""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load( + "@score_tooling//bazel/rules/rules_score:providers.bzl", + "AssumedSystemRequirementsInfo", + "AssumptionsOfUseInfo", + "ComponentRequirementsInfo", + "FeatureRequirementsInfo", + "SphinxSourcesInfo", +) + +# ============================================================================ +# assumed_system_requirements – RST input +# ============================================================================ + +def _asr_rst_output_test_impl(ctx): + """assumed_system_requirements from RST produces rendered .rst output files.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + files = target_under_test[DefaultInfo].files.to_list() + asserts.true( + env, + len(files) > 0, + "assumed_system_requirements from RST should produce output files", + ) + rst_files = [f for f in files if f.basename.endswith(".rst")] + asserts.true( + env, + len(rst_files) > 0, + "assumed_system_requirements from RST should produce a rendered .rst file", + ) + + return analysistest.end(env) + +asr_rst_output_test = analysistest.make(_asr_rst_output_test_impl) + +def _asr_rst_provider_test_impl(ctx): + """assumed_system_requirements from RST exposes AssumedSystemRequirementsInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + AssumedSystemRequirementsInfo in target_under_test, + "assumed_system_requirements from RST should provide AssumedSystemRequirementsInfo", + ) + info = target_under_test[AssumedSystemRequirementsInfo] + asserts.true( + env, + info.name != None, + "AssumedSystemRequirementsInfo should have a name field", + ) + asserts.true( + env, + info.srcs != None, + "AssumedSystemRequirementsInfo should have a srcs field", + ) + + return analysistest.end(env) + +asr_rst_provider_test = analysistest.make(_asr_rst_provider_test_impl) + +def _asr_rst_sphinx_test_impl(ctx): + """assumed_system_requirements from RST exposes SphinxSourcesInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + SphinxSourcesInfo in target_under_test, + "assumed_system_requirements from RST should provide SphinxSourcesInfo", + ) + + return analysistest.end(env) + +asr_rst_sphinx_test = analysistest.make(_asr_rst_sphinx_test_impl) + +# ============================================================================ +# feature_requirements – RST input +# ============================================================================ + +def _feat_req_rst_provider_test_impl(ctx): + """feature_requirements from RST exposes FeatureRequirementsInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + FeatureRequirementsInfo in target_under_test, + "feature_requirements from RST should provide FeatureRequirementsInfo", + ) + info = target_under_test[FeatureRequirementsInfo] + asserts.true( + env, + info.name != None, + "FeatureRequirementsInfo should have a name field", + ) + asserts.true( + env, + info.srcs != None, + "FeatureRequirementsInfo should have a srcs field", + ) + + return analysistest.end(env) + +feat_req_rst_provider_test = analysistest.make(_feat_req_rst_provider_test_impl) + +def _feat_req_rst_sphinx_test_impl(ctx): + """feature_requirements from RST exposes SphinxSourcesInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + SphinxSourcesInfo in target_under_test, + "feature_requirements from RST should provide SphinxSourcesInfo", + ) + + return analysistest.end(env) + +feat_req_rst_sphinx_test = analysistest.make(_feat_req_rst_sphinx_test_impl) + +# ============================================================================ +# component_requirements – RST input +# ============================================================================ + +def _comp_req_rst_provider_test_impl(ctx): + """component_requirements from RST exposes ComponentRequirementsInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + ComponentRequirementsInfo in target_under_test, + "component_requirements from RST should provide ComponentRequirementsInfo", + ) + info = target_under_test[ComponentRequirementsInfo] + asserts.true( + env, + info.name != None, + "ComponentRequirementsInfo should have a name field", + ) + asserts.true( + env, + info.srcs != None, + "ComponentRequirementsInfo should have a srcs field", + ) + + return analysistest.end(env) + +comp_req_rst_provider_test = analysistest.make(_comp_req_rst_provider_test_impl) + +def _comp_req_rst_sphinx_test_impl(ctx): + """component_requirements from RST exposes SphinxSourcesInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + SphinxSourcesInfo in target_under_test, + "component_requirements from RST should provide SphinxSourcesInfo", + ) + + return analysistest.end(env) + +comp_req_rst_sphinx_test = analysistest.make(_comp_req_rst_sphinx_test_impl) + +# ============================================================================ +# assumptions_of_use – RST input +# ============================================================================ + +def _aous_rst_provider_test_impl(ctx): + """assumptions_of_use from RST exposes AssumptionsOfUseInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + AssumptionsOfUseInfo in target_under_test, + "assumptions_of_use from RST should provide AssumptionsOfUseInfo", + ) + info = target_under_test[AssumptionsOfUseInfo] + asserts.true( + env, + info.name != None, + "AssumptionsOfUseInfo should have a name field", + ) + asserts.true( + env, + info.srcs != None, + "AssumptionsOfUseInfo should have a srcs field", + ) + + return analysistest.end(env) + +aous_rst_provider_test = analysistest.make(_aous_rst_provider_test_impl) + +def _aous_rst_sphinx_test_impl(ctx): + """assumptions_of_use from RST exposes SphinxSourcesInfo.""" + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + + asserts.true( + env, + SphinxSourcesInfo in target_under_test, + "assumptions_of_use from RST should provide SphinxSourcesInfo", + ) + + return analysistest.end(env) + +aous_rst_sphinx_test = analysistest.make(_aous_rst_sphinx_test_impl) + +# ============================================================================ +# Test Suite +# ============================================================================ + +def requirements_rst_test_suite(name): + """Register all RST-based requirement tests. + + Args: + name: Name for the test_suite target. + """ + native.test_suite( + name = name, + tests = [ + ":asr_rst_output_test", + ":asr_rst_provider_test", + ":asr_rst_sphinx_test", + ":feat_req_rst_provider_test", + ":feat_req_rst_sphinx_test", + ":comp_req_rst_provider_test", + ":comp_req_rst_sphinx_test", + ":aous_rst_provider_test", + ":aous_rst_sphinx_test", + ], + ) diff --git a/bazel/rules/rules_score/test/rst_to_trlc_test.py b/bazel/rules/rules_score/test/rst_to_trlc_test.py new file mode 100644 index 00000000..7e5d5789 --- /dev/null +++ b/bazel/rules/rules_score/test/rst_to_trlc_test.py @@ -0,0 +1,755 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Unit tests for the RST-to-TRLC converter. + +Tests are structured around the four S-CORE requirement types: + - assumed_system_req → ScoreReq.AssumedSystemReq + - feat_req → ScoreReq.FeatReq + - comp_req → ScoreReq.CompReq + - aou_req → ScoreReq.AoU +""" + +import tempfile +import unittest +from io import StringIO +from pathlib import Path + +from rst_to_trlc import ( + _ALLOWED_RST_ATTRS, + _collect_body, + _collect_refs, + _escape, + convert, + DIRECTIVE_TO_TRLC, + parse_directives, + render_trlc, + SAFETY_MAP, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _rst(*lines: str) -> str: + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# DIRECTIVE_TO_TRLC – only S-CORE types are present +# --------------------------------------------------------------------------- + + +class TestDirectiveToTrlc(unittest.TestCase): + def test_assumed_system_req(self): + self.assertEqual( + DIRECTIVE_TO_TRLC["assumed_system_req"], "ScoreReq.AssumedSystemReq" + ) + + def test_feat_req(self): + self.assertEqual(DIRECTIVE_TO_TRLC["feat_req"], "ScoreReq.FeatReq") + + def test_comp_req(self): + self.assertEqual(DIRECTIVE_TO_TRLC["comp_req"], "ScoreReq.CompReq") + + def test_aou_req(self): + self.assertEqual(DIRECTIVE_TO_TRLC["aou_req"], "ScoreReq.AoU") + + def test_non_score_directives_not_present(self): + """Directives outside the S-CORE model must not appear in the map.""" + for alias in ( + "asr_req", + "feature_req", + "component_req", + "assumption_of_use", + "comp_saf_fmea", + "comp_arc_sta", + "stkh_req", + ): + self.assertNotIn(alias, DIRECTIVE_TO_TRLC, f"{alias} should not be mapped") + + +# --------------------------------------------------------------------------- +# ALLOWED_RST_ATTRS – whitelist is complete and minimal +# --------------------------------------------------------------------------- + + +class TestAllowedRstAttrs(unittest.TestCase): + def test_required_attrs_present(self): + for attr in ( + "id", + "safety", + "satisfies", + "derived_from", + "rationale", + "version", + ): + self.assertIn(attr, _ALLOWED_RST_ATTRS) + + def test_sphinx_only_attrs_absent(self): + """Sphinx-needs-only attributes must not be in the whitelist.""" + for attr in ( + "reqtype", + "security", + "valid_from", + "valid_until", + "belongs_to", + "tags", + "fulfils", + "mitigates", + "status", + ): + self.assertNotIn(attr, _ALLOWED_RST_ATTRS, f"{attr} should be ignored") + + +# --------------------------------------------------------------------------- +# SAFETY_MAP – only QM, B, D exist in ScoreReq.Asil +# --------------------------------------------------------------------------- + + +class TestSafetyMap(unittest.TestCase): + def test_qm(self): + self.assertEqual(SAFETY_MAP["QM"], "ScoreReq.Asil.QM") + + def test_asil_b(self): + self.assertEqual(SAFETY_MAP["ASIL_B"], "ScoreReq.Asil.B") + + def test_asil_d(self): + self.assertEqual(SAFETY_MAP["ASIL_D"], "ScoreReq.Asil.D") + + def test_asil_a(self): + self.assertEqual(SAFETY_MAP["ASIL_A"], "ScoreReq.Asil.A") + + def test_asil_c(self): + self.assertEqual(SAFETY_MAP["ASIL_C"], "ScoreReq.Asil.C") + + def test_all_five_levels_present(self): + """All five ASIL levels must be present in the map.""" + for key in ("QM", "ASIL_A", "ASIL_B", "ASIL_C", "ASIL_D"): + self.assertIn(key, SAFETY_MAP) + + +# --------------------------------------------------------------------------- +# parse_directives – S-CORE directive recognition +# --------------------------------------------------------------------------- + + +class TestParseDirectives(unittest.TestCase): + # --- Assumed System Requirements --- + + def test_parses_assumed_system_req(self): + rst = _rst( + ".. assumed_system_req:: Minimal Interface", + " :id: asr_req__test__001", + " :safety: ASIL_B", + " :status: valid", + "", + " The system shall provide a minimal interface.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + item = result[0] + self.assertEqual(item["directive"], "assumed_system_req") + self.assertEqual(item["title"], "Minimal Interface") + self.assertEqual(item["fields"]["id"], "asr_req__test__001") + self.assertEqual(item["fields"]["safety"], "ASIL_B") + self.assertIn("minimal interface", item["body"]) + + def test_parses_assumed_system_req_with_rationale(self): + rst = _rst( + ".. assumed_system_req:: With Rationale", + " :id: asr_req__test__003", + " :safety: QM", + " :rationale: Needed for safety analysis.", + "", + " The system shall do something.", + ) + result = parse_directives(rst) + self.assertEqual( + result[0]["fields"]["rationale"], "Needed for safety analysis." + ) + + # --- Feature Requirements --- + + def test_parses_feat_req(self): + rst = _rst( + ".. feat_req:: Mock Interface", + " :id: feat_req__test__001", + " :safety: ASIL_B", + " :satisfies: asr_req__test__001", + "", + " The component shall provide a mock interface.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["directive"], "feat_req") + self.assertEqual(result[0]["fields"]["satisfies"], "asr_req__test__001") + + def test_parses_feat_req_with_derived_from(self): + """derived_from is an alternative RST field name for satisfies.""" + rst = _rst( + ".. feat_req:: Another Feature", + " :id: feat_req__test__002", + " :safety: ASIL_B", + " :derived_from: asr_req__test__001", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(result[0]["fields"]["derived_from"], "asr_req__test__001") + + # --- Component Requirements --- + + def test_parses_comp_req(self): + rst = _rst( + ".. comp_req:: Return Value", + " :id: comp_req__test__001", + " :safety: ASIL_B", + " :satisfies: feat_req__test__001", + "", + " The function shall return 42.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["directive"], "comp_req") + + def test_parses_comp_req_without_satisfies(self): + """satisfies is optional for comp_req.""" + rst = _rst( + ".. comp_req:: Standalone", + " :id: comp_req__test__002", + " :safety: QM", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertNotIn("satisfies", result[0]["fields"]) + + # --- Assumptions of Use --- + + def test_parses_aou_req(self): + rst = _rst( + ".. aou_req:: Operating Conditions", + " :id: aou_req__test__001", + " :safety: ASIL_B", + "", + " The SEooC shall operate within defined conditions.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["directive"], "aou_req") + + # --- General behaviour --- + + def test_ignores_non_score_directives(self): + rst = _rst( + ".. image:: diagram.png", + ".. note::", + " Some note.", + "", + ".. feat_req:: Real Req", + " :id: feat_req__test__001", + " :safety: QM", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["directive"], "feat_req") + + def test_ignores_stkh_req(self): + """stkh_req has no corresponding TRLC type and must be ignored.""" + rst = _rst( + ".. stkh_req:: Platform Requirement", + " :id: stkh_req__platform__001", + " :safety: ASIL_B", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(result, []) + + def test_returns_empty_for_plain_rst(self): + self.assertEqual(parse_directives("Plain RST text without directives.\n"), []) + + def test_parses_multiple_directives(self): + rst = _rst( + ".. assumed_system_req:: First", + " :id: asr_req__test__001", + " :safety: QM", + "", + " First body.", + "", + ".. feat_req:: Second", + " :id: feat_req__test__001", + " :safety: ASIL_B", + " :satisfies: asr_req__test__001", + "", + " Second body.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["directive"], "assumed_system_req") + self.assertEqual(result[1]["directive"], "feat_req") + + def test_sphinx_only_fields_collected_but_not_in_whitelist(self): + """Fields like reqtype/security are collected but must not affect TRLC output.""" + rst = _rst( + ".. feat_req:: Has Sphinx Fields", + " :id: feat_req__test__001", + " :reqtype: Functional", + " :security: NO", + " :safety: ASIL_B", + " :valid_from: v0.0.1", + " :belongs_to: feat__some_feature", + " :satisfies: asr_req__test__001", + "", + " Body.", + ) + result = parse_directives(rst) + fields = result[0]["fields"] + # Fields are parsed from RST... + self.assertIn("reqtype", fields) + self.assertIn("security", fields) + # ...but none of the non-whitelisted ones appear in _ALLOWED_RST_ATTRS + for key in ("reqtype", "security", "valid_from", "belongs_to"): + self.assertNotIn(key, _ALLOWED_RST_ATTRS) + + def test_strips_rst_bold_markup_from_body(self): + rst = _rst( + ".. assumed_system_req:: Markup", + " :id: asr_req__test__001", + " :safety: QM", + "", + " The system shall be **robust** and **fast**.", + ) + result = parse_directives(rst) + self.assertNotIn("**", result[0]["body"]) + self.assertIn("robust", result[0]["body"]) + + +# --------------------------------------------------------------------------- +# render_trlc – TRLC output for each S-CORE type +# --------------------------------------------------------------------------- + + +class TestRenderTrlc(unittest.TestCase): + def _single(self, directive, fields=None, body="The component shall do something."): + base = {"id": f"{directive}__test__001", "safety": "ASIL_B"} + if fields: + base.update(fields) + return [{"directive": directive, "title": "Test", "fields": base, "body": body}] + + # --- AssumedSystemReq --- + + def test_assumed_system_req_produces_type(self): + out = render_trlc(self._single("assumed_system_req"), "Pkg", "") + self.assertIn("ScoreReq.AssumedSystemReq", out) + + def test_assumed_system_req_adds_rationale_placeholder_when_absent(self): + out = render_trlc(self._single("assumed_system_req"), "Pkg", "") + self.assertIn('rationale = "TODO: add rationale"', out) + + def test_assumed_system_req_uses_rationale_from_rst_when_present(self): + items = self._single( + "assumed_system_req", {"rationale": "Needed for ISO 26262 compliance."} + ) + out = render_trlc(items, "Pkg", "") + self.assertIn('"Needed for ISO 26262 compliance."', out) + self.assertNotIn("TODO: add rationale", out) + + def test_assumed_system_req_rationale_is_escaped(self): + items = self._single("assumed_system_req", {"rationale": 'Has "quotes"'}) + out = render_trlc(items, "Pkg", "") + self.assertIn('\\"quotes\\"', out) + + # --- FeatReq --- + + def test_feat_req_produces_feat_req_type(self): + out = render_trlc(self._single("feat_req"), "Pkg", "") + self.assertIn("ScoreReq.FeatReq", out) + + def test_feat_req_no_rationale(self): + out = render_trlc(self._single("feat_req"), "Pkg", "") + self.assertNotIn("rationale", out) + + def test_feat_req_satisfies_maps_to_derived_from(self): + items = self._single("feat_req", {"satisfies": "asr_req__test__001"}) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertIn("derived_from", out) + self.assertIn("AsrPkg.asr_req__test__001@1", out) + + def test_feat_req_derived_from_field_also_works(self): + items = self._single("feat_req", {"derived_from": "asr_req__test__001"}) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertIn("AsrPkg.asr_req__test__001@1", out) + + def test_feat_req_imports_ref_package_when_refs_present(self): + items = self._single("feat_req", {"satisfies": "asr_req__test__001"}) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertIn("import AsrPkg", out) + + # --- CompReq --- + + def test_comp_req_produces_comp_req_type(self): + out = render_trlc(self._single("comp_req"), "Pkg", "") + self.assertIn("ScoreReq.CompReq", out) + + def test_comp_req_satisfies_maps_to_derived_from(self): + items = self._single("comp_req", {"satisfies": "feat_req__test__001"}) + out = render_trlc(items, "CompPkg", "FeatPkg") + self.assertIn("derived_from", out) + self.assertIn("FeatPkg.feat_req__test__001@1", out) + + def test_comp_req_without_satisfies_has_no_derived_from(self): + out = render_trlc(self._single("comp_req"), "Pkg", "") + self.assertNotIn("derived_from", out) + + # --- AoU --- + + def test_aou_req_produces_aou_type(self): + out = render_trlc(self._single("aou_req"), "Pkg", "") + self.assertIn("ScoreReq.AoU", out) + + def test_aou_req_no_rationale(self): + out = render_trlc(self._single("aou_req"), "Pkg", "") + self.assertNotIn("rationale", out) + + # --- Ignored Sphinx-only attributes --- + + def test_sphinx_only_fields_do_not_appear_in_output(self): + """reqtype, security, valid_from, belongs_to must be silently dropped.""" + items = self._single( + "feat_req", + { + "satisfies": "asr_req__test__001", + "reqtype": "Functional", + "security": "NO", + "valid_from": "v0.0.1", + "valid_until": "v1.0.0", + "belongs_to": "feat__some_feature", + "tags": "important", + }, + ) + out = render_trlc(items, "FeatPkg", "AsrPkg") + for field in ( + "reqtype", + "security", + "valid_from", + "valid_until", + "belongs_to", + "tags", + ): + self.assertNotIn( + field, out, f"'{field}' should be dropped from TRLC output" + ) + + # --- General TRLC structure --- + + def test_output_contains_package_declaration(self): + out = render_trlc(self._single("assumed_system_req"), "MyPkg", "") + self.assertIn("package MyPkg", out) + + def test_output_imports_score_req(self): + out = render_trlc(self._single("assumed_system_req"), "MyPkg", "") + self.assertIn("import ScoreReq", out) + + def test_no_extra_import_when_no_refs(self): + out = render_trlc(self._single("assumed_system_req"), "MyPkg", "SomePkg") + self.assertNotIn("import SomePkg", out) + + def test_safety_asil_a_written_as_a(self): + items = [ + { + "directive": "assumed_system_req", + "title": "T", + "fields": {"id": "x", "safety": "ASIL_A"}, + "body": "b.", + } + ] + out = render_trlc(items, "P", "") + self.assertIn("ScoreReq.Asil.A", out) + + def test_description_escapes_double_quotes(self): + items = [ + { + "directive": "assumed_system_req", + "title": "T", + "fields": {"id": "x", "safety": "QM"}, + "body": 'He said "hi".', + } + ] + out = render_trlc(items, "P", "") + self.assertIn('\\"hi\\"', out) + + def test_uses_id_as_record_name(self): + out = render_trlc(self._single("assumed_system_req"), "P", "") + self.assertIn("assumed_system_req__test__001", out) + + def test_derives_name_from_title_when_no_id(self): + items = [ + { + "directive": "assumed_system_req", + "title": "My Cool Req", + "fields": {"safety": "QM"}, + "body": "b.", + } + ] + out = render_trlc(items, "P", "") + self.assertIn("My_Cool_Req", out) + + def test_version_defaults_to_1(self): + out = render_trlc(self._single("assumed_system_req"), "P", "") + self.assertIn("version = 1", out) + + def test_empty_list_produces_header_only(self): + out = render_trlc([], "EmptyPkg", "") + self.assertIn("package EmptyPkg", out) + self.assertNotIn("description", out) + + +# --------------------------------------------------------------------------- +# _collect_refs – only satisfies and derived_from are cross-references +# --------------------------------------------------------------------------- + + +class TestCollectRefs(unittest.TestCase): + def test_satisfies_is_a_ref_field(self): + self.assertEqual( + _collect_refs({"satisfies": "asr_req__test__001"}), ["asr_req__test__001"] + ) + + def test_derived_from_is_a_ref_field(self): + self.assertEqual( + _collect_refs({"derived_from": "asr_req__test__001"}), + ["asr_req__test__001"], + ) + + def test_comma_separated_refs(self): + self.assertEqual( + _collect_refs({"satisfies": "req_001, req_002"}), + ["req_001", "req_002"], + ) + + def test_fulfils_is_not_a_ref_field(self): + """fulfils is not part of the S-CORE process and must not produce refs.""" + self.assertEqual(_collect_refs({"fulfils": "some_req"}), []) + + def test_mitigates_is_not_a_ref_field(self): + """mitigates is a String field on AoU/CompReq, not a cross-reference.""" + self.assertEqual(_collect_refs({"mitigates": "some_req"}), []) + + def test_returns_empty_when_no_ref_fields(self): + self.assertEqual(_collect_refs({"safety": "QM", "reqtype": "Functional"}), []) + + +# --------------------------------------------------------------------------- +# _collect_body – blank-line handling consistency +# --------------------------------------------------------------------------- + + +class TestCollectBody(unittest.TestCase): + def test_collects_indented_body(self): + lines = [" Body text.", ""] + body, _ = _collect_body(lines, 0) + self.assertEqual(body, "Body text.") + + def test_stops_at_unindented_line(self): + lines = [" Indented.", "Unindented."] + body, i = _collect_body(lines, 0) + self.assertEqual(body, "Indented.") + self.assertEqual(i, 1) + + def test_interior_blank_line_no_double_space(self): + lines = [" First.", "", " Second.", ""] + body, _ = _collect_body(lines, 0) + self.assertNotIn(" ", body) + self.assertIn("First.", body) + self.assertIn("Second.", body) + + def test_early_return_and_normal_exit_consistent(self): + """Both return paths must filter empty parts identically.""" + lines_early = [" A.", "", "Unindented"] + body_early, _ = _collect_body(lines_early, 0) + + lines_normal = [" A.", "Unindented"] + body_normal, _ = _collect_body(lines_normal, 0) + + self.assertEqual(body_early, body_normal) + + +# --------------------------------------------------------------------------- +# _escape +# --------------------------------------------------------------------------- + + +class TestEscape(unittest.TestCase): + def test_double_quotes(self): + self.assertEqual(_escape('say "hi"'), 'say \\"hi\\"') + + def test_backslashes(self): + self.assertEqual(_escape("a\\b"), "a\\\\b") + + def test_no_change_on_clean_string(self): + self.assertEqual(_escape("hello"), "hello") + + +# --------------------------------------------------------------------------- +# convert – integration across all S-CORE types +# --------------------------------------------------------------------------- + + +class TestConvert(unittest.TestCase): + def _convert(self, rst_content: str, filename="req.rst", **kwargs) -> str: + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / filename + src.write_text(rst_content, encoding="utf-8") + out = Path(tmpdir) / (src.stem + ".trlc") + convert(src, out, **kwargs) + return out.read_text(encoding="utf-8") + + def test_assumed_system_req_round_trip(self): + rst = ( + ".. assumed_system_req:: Min Interface\n" + " :id: asr_req__test__001\n" + " :safety: ASIL_B\n" + "\n" + " The system shall provide a minimal interface.\n" + ) + out = self._convert(rst) + self.assertIn("ScoreReq.AssumedSystemReq", out) + self.assertIn("asr_req__test__001", out) + self.assertIn("ScoreReq.Asil.B", out) + self.assertIn("rationale", out) + + def test_assumed_system_req_with_rationale_from_rst(self): + rst = ( + ".. assumed_system_req:: Min Interface\n" + " :id: asr_req__test__001\n" + " :safety: ASIL_B\n" + " :rationale: Required by ISO 26262.\n" + "\n" + " The system shall provide a minimal interface.\n" + ) + out = self._convert(rst) + self.assertIn("Required by ISO 26262.", out) + self.assertNotIn("TODO: add rationale", out) + + def test_feat_req_with_satisfies(self): + rst = ( + ".. feat_req:: Mock Interface\n" + " :id: feat_req__test__001\n" + " :safety: ASIL_B\n" + " :satisfies: asr_req__test__001\n" + "\n" + " The component shall provide a mock interface.\n" + ) + out = self._convert(rst, ref_package="AsrPkg") + self.assertIn("ScoreReq.FeatReq", out) + self.assertIn("AsrPkg.asr_req__test__001@1", out) + + def test_feat_req_sphinx_attrs_not_in_output(self): + rst = ( + ".. feat_req:: Full Template\n" + " :id: feat_req__test__001\n" + " :reqtype: Functional\n" + " :security: NO\n" + " :safety: ASIL_B\n" + " :satisfies: asr_req__test__001\n" + " :valid_from: v0.0.1\n" + " :belongs_to: feat__some_feature\n" + " :status: invalid\n" + "\n" + " The component shall provide a mock interface.\n" + ) + out = self._convert(rst, ref_package="AsrPkg") + for field in ("reqtype", "security", "valid_from", "belongs_to", "status"): + self.assertNotIn(field, out) + + def test_comp_req_round_trip(self): + rst = ( + ".. comp_req:: Return Value\n" + " :id: comp_req__test__001\n" + " :safety: ASIL_B\n" + " :satisfies: feat_req__test__001\n" + "\n" + " The function shall return 42.\n" + ) + out = self._convert(rst, ref_package="FeatPkg") + self.assertIn("ScoreReq.CompReq", out) + self.assertIn("FeatPkg.feat_req__test__001@1", out) + + def test_aou_req_round_trip(self): + rst = ( + ".. aou_req:: Operating Conditions\n" + " :id: aou_req__test__001\n" + " :safety: ASIL_B\n" + "\n" + " The SEooC shall operate within defined conditions.\n" + ) + out = self._convert(rst) + self.assertIn("ScoreReq.AoU", out) + self.assertNotIn("rationale", out) + + def test_package_name_derived_from_file_stem(self): + rst = ( + ".. assumed_system_req:: Pkg Test\n" + " :id: asr_req__test__001\n" + " :safety: QM\n" + "\n" + " Body.\n" + ) + out = self._convert(rst, filename="my_requirements.rst") + self.assertIn("package MyRequirements", out) + + def test_empty_rst_warns_and_returns_zero(self): + import sys + + buf = StringIO() + old_stderr, sys.stderr = sys.stderr, buf + try: + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "empty.rst" + src.write_text("No directives here.\n", encoding="utf-8") + count = convert(src, Path(tmpdir) / "empty.trlc") + finally: + sys.stderr = old_stderr + self.assertEqual(count, 0) + self.assertIn("WARNING", buf.getvalue()) + + def test_stkh_req_is_skipped(self): + rst = ( + ".. stkh_req:: Platform Requirement\n" + " :id: stkh_req__platform__001\n" + " :safety: ASIL_B\n" + "\n" + " The platform shall do something.\n" + ) + import sys + + buf = StringIO() + old_stderr, sys.stderr = sys.stderr, buf + try: + out = self._convert(rst) + finally: + sys.stderr = old_stderr + # stkh_req has no TRLC mapping → treated as no directives found + self.assertIn("WARNING", buf.getvalue()) + self.assertNotIn("stkh_req", out) + + +if __name__ == "__main__": + unittest.main()