diff --git a/bazel/rules/rules_score/private/assumed_system_requirements.bzl b/bazel/rules/rules_score/private/assumed_system_requirements.bzl index ec044f9c..13fc5238 100644 --- a/bazel/rules/rules_score/private/assumed_system_requirements.bzl +++ b/bazel/rules/rules_score/private/assumed_system_requirements.bzl @@ -33,6 +33,7 @@ def assumed_system_requirements( spec = Label("//bazel/rules/rules_score/trlc/config:score_requirements_model"), lobster_config = Label("//bazel/rules/rules_score/lobster/config:assumed_system_requirement"), ref_package = "", + package = "", image_srcs = [], **kwargs): """Define Assumed System Requirements following S-CORE process guidelines. @@ -58,6 +59,11 @@ def assumed_system_requirements( Override this when using a custom requirements model. lobster_config: Optional Lobster extraction config label. Defaults to the S-CORE assumed system requirement config. + package: Optional TRLC package name override, only used when srcs + contains .rst files (ignored for raw .trlc srcs). Defaults to a + name derived from the .rst file's stem; set this explicitly to + avoid collisions when multiple requirement targets are converted + from same-named .rst files (e.g. multiple "index.rst"). visibility: Bazel visibility specification for the generated targets. Generated Targets: @@ -87,6 +93,7 @@ def assumed_system_requirements( lobster_config = lobster_config, spec = spec, ref_package = ref_package, + package = package, image_srcs = image_srcs, **kwargs ) diff --git a/bazel/rules/rules_score/private/component_requirements.bzl b/bazel/rules/rules_score/private/component_requirements.bzl index fd138eff..f163e90d 100644 --- a/bazel/rules/rules_score/private/component_requirements.bzl +++ b/bazel/rules/rules_score/private/component_requirements.bzl @@ -32,6 +32,7 @@ def component_requirements( spec = Label("//bazel/rules/rules_score/trlc/config:score_requirements_model"), lobster_config = Label("//bazel/rules/rules_score/lobster/config:component_requirement"), ref_package = "", + package = "", image_srcs = [], **kwargs): """Define component requirements following S-CORE process guidelines. @@ -62,6 +63,11 @@ def component_requirements( 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). + package: Optional TRLC package name override, only used when srcs + contains .rst files (ignored for raw .trlc srcs). Defaults to a + name derived from the .rst file's stem; set this explicitly to + avoid collisions when multiple requirement targets are converted + from same-named .rst files (e.g. multiple "index.rst"). visibility: Bazel visibility specification for the generated targets. Generated Targets: @@ -86,6 +92,7 @@ def component_requirements( lobster_config = lobster_config, spec = spec, ref_package = ref_package, + package = package, image_srcs = image_srcs, **kwargs ) diff --git a/bazel/rules/rules_score/private/feature_requirements.bzl b/bazel/rules/rules_score/private/feature_requirements.bzl index 15adafbf..6800bdec 100644 --- a/bazel/rules/rules_score/private/feature_requirements.bzl +++ b/bazel/rules/rules_score/private/feature_requirements.bzl @@ -33,6 +33,7 @@ def feature_requirements( spec = Label("//bazel/rules/rules_score/trlc/config:score_requirements_model"), lobster_config = Label("//bazel/rules/rules_score/lobster/config:feature_requirement"), ref_package = "", + package = "", image_srcs = [], **kwargs): """Define feature requirements following S-CORE process guidelines. @@ -57,6 +58,11 @@ def feature_requirements( (``@score_tooling//bazel/rules/rules_score/trlc/config:score_requirements_model``). lobster_config: Optional Lobster extraction config label. Defaults to the S-CORE feature requirement config. + package: Optional TRLC package name override, only used when srcs + contains .rst files (ignored for raw .trlc srcs). Defaults to a + name derived from the .rst file's stem; set this explicitly to + avoid collisions when multiple requirement targets are converted + from same-named .rst files (e.g. multiple "index.rst"). visibility: Bazel visibility specification for the generated targets. Generated Targets: @@ -86,6 +92,7 @@ def feature_requirements( lobster_config = lobster_config, spec = spec, ref_package = ref_package, + package = package, image_srcs = image_srcs, **kwargs ) diff --git a/bazel/rules/rules_score/private/requirements.bzl b/bazel/rules/rules_score/private/requirements.bzl index b7a96171..1c4ffa8a 100644 --- a/bazel/rules/rules_score/private/requirements.bzl +++ b/bazel/rules/rules_score/private/requirements.bzl @@ -26,6 +26,20 @@ load("//bazel/rules/rules_score/private:rst_to_trlc.bzl", "rst_to_trlc") _DEFAULT_SPEC = Label("//bazel/rules/rules_score/trlc/config:score_requirements_model") +# Restricts which RST directive names are converted per req_kind, so a +# shared .rst file (e.g. one that also carries aou_req directives consumed +# separately by assumptions_of_use()) doesn't leak unrelated directive types +# into a given requirements target. "assumed_system" additionally accepts +# stkh_req: Stakeholder Requirements have no TRLC representation of their +# own and are the intended real-world source for AssumedSystemReq records +# (see rst_to_trlc.py's DIRECTIVE_TO_TRLC mapping). +_REQ_KIND_TO_DIRECTIVES = { + "assumed_system": ["assumed_system_req", "stkh_req"], + "feature": ["feat_req"], + "component": ["comp_req"], + "aou": ["aou_req"], +} + # ============================================================================ # Private Rule Implementation # ============================================================================ @@ -196,6 +210,7 @@ def score_requirements_rule( deps = [], spec = [], ref_package = "", + package = "", **kwargs): """Macro wrapper around _score_requirements_rule with RST support. @@ -215,6 +230,13 @@ def score_requirements_rule( ref_package: TRLC package prefix used for derived_from cross-references when converting RST sources (e.g. "AssumedSystemRequirements" for feature requirements that derive from ASR). + package: Optional TRLC package name override for the .trlc file(s) + generated from .rst srcs. Only applies to the RST conversion path; + .trlc sources are passed through unchanged and keep whatever + package name their own source declares. Defaults to a name derived + from the .rst file's stem (see rst_to_trlc.py), which can collide + when multiple requirement targets are converted from same-named + files (e.g. multiple "index.rst"). Returns: List of resolved labels corresponding to srcs (after any .rst-to-.trlc @@ -223,6 +245,7 @@ def score_requirements_rule( """ extra_spec = spec if type(spec) == type([]) else [spec] merged_spec = [_DEFAULT_SPEC] + [s for s in extra_spec if s != _DEFAULT_SPEC] + only_types = _REQ_KIND_TO_DIRECTIVES.get(req_kind, []) trlc_srcs = [] extra_deps = [] resolved_srcs = [] @@ -233,6 +256,8 @@ def score_requirements_rule( name = gen_name, srcs = [src], ref_package = ref_package, + package = package, + only_types = only_types, ) trlc_srcs.append(":" + gen_name) resolved_srcs.append(":" + gen_name) diff --git a/bazel/rules/rules_score/private/rst_to_trlc.bzl b/bazel/rules/rules_score/private/rst_to_trlc.bzl index 196b8c3f..a3c01c36 100644 --- a/bazel/rules/rules_score/private/rst_to_trlc.bzl +++ b/bazel/rules/rules_score/private/rst_to_trlc.bzl @@ -16,7 +16,7 @@ load("@trlc//:trlc.bzl", "trlc_requirements") load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_log_level") -def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = ""): +def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = "", only_types = []): """Convert any .rst entries in srcs to trlc_requirements targets. For each .rst entry a pair of intermediate targets is generated: @@ -34,6 +34,12 @@ def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = ""): generated .trlc files (e.g. parent requirement packages). ref_package: TRLC package prefix for derived_from cross-references written into the generated .trlc content. + only_types: Optional list of RST directive names (e.g. ["aou_req"]) + to restrict conversion to. Useful when the same .rst file is + shared as srcs by more than one rule (e.g. it contains both + comp_req and aou_req directives, consumed separately by + component_requirements() and assumptions_of_use()). Defaults to + all directive types supported by rst_to_trlc.py. Returns: List of srcs where .rst entries are replaced by generated trlc labels. @@ -47,6 +53,7 @@ def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = ""): name = gen_name, srcs = [src], ref_package = ref_package, + only_types = only_types, ) trlc_requirements( name = trlc_name, @@ -63,7 +70,17 @@ 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) + # Declared under this rule's own name (not sibling = src): a single + # .rst file can be the srcs of more than one rst_to_trlc rule (e.g. + # the same file supplying both comp_req and aou_req directives to + # component_requirements() and assumptions_of_use() respectively). + # Since sibling = src ties the output to the *source's* directory, + # two such rules would declare the exact same output path and + # conflict. Namespacing by rule name also sidesteps the constraint + # that a sibling-declared file must live in the same Bazel package + # as the rule (the source file's package and the rule's package can + # now differ). + out = ctx.actions.declare_file("{}/{}.trlc".format(ctx.label.name, src.basename[:-4])) outs.append(out) args = ctx.actions.args() @@ -76,6 +93,9 @@ def _rst_to_trlc_impl(ctx): if ctx.attr.package: args.add("--package") args.add(ctx.attr.package) + if ctx.attr.only_types: + args.add("--only-types") + args.add(",".join(ctx.attr.only_types)) args.add("--log-level") args.add(get_log_level(ctx)) @@ -114,6 +134,10 @@ rst_to_trlc = rule( default = "", doc = "Optional TRLC package name override; defaults to the input file stem.", ), + "only_types": attr.string_list( + default = [], + doc = "Optional allowlist of RST directive names to convert; defaults to all supported types.", + ), }, **VERBOSITY_ATTR ), diff --git a/bazel/rules/rules_score/src/fmea_assembler.py b/bazel/rules/rules_score/src/fmea_assembler.py index 655b2a58..78f39173 100644 --- a/bazel/rules/rules_score/src/fmea_assembler.py +++ b/bazel/rules/rules_score/src/fmea_assembler.py @@ -45,7 +45,7 @@ _CM_TABLE_COLUMNS = {"safety": "ASIL", "description": "Description"} # Overview summary table columns (one row per failure mode). _FM_TABLE_COLUMNS = { - "guideword": "Guideword", + "guidewords": "Guideword", "safety": "ASIL", "interface": "Interface", } @@ -150,9 +150,12 @@ def _attr_grid(obj: object) -> _Directive | None: interface/failure-effect as titled cards; a gutter separates the rows.""" fields = obj.to_python_dict() items = [] - guideword = fields.get("guideword") - if guideword: - items.append(_grid_item(_badge(_GUIDEWORD_BADGE, guideword), {"class": "sd-text-center"})) + guidewords = fields.get("guidewords") + if guidewords: + guideword_text = ( + ", ".join(str(g) for g in guidewords) if isinstance(guidewords, list) else str(guidewords) + ) + items.append(_grid_item(_badge(_GUIDEWORD_BADGE, guideword_text), {"class": "sd-text-center"})) safety = fields.get("safety") if safety: role = _ASIL_BADGE.get(safety, _DEFAULT_BADGE) diff --git a/bazel/rules/rules_score/src/rst_to_trlc.py b/bazel/rules/rules_score/src/rst_to_trlc.py index 59e429d3..55ab2584 100644 --- a/bazel/rules/rules_score/src/rst_to_trlc.py +++ b/bazel/rules/rules_score/src/rst_to_trlc.py @@ -32,6 +32,13 @@ DIRECTIVE_TO_TRLC: dict[str, str] = { # Assumed System Requirements (root of the S-CORE traceability chain) "assumed_system_req": "ScoreReq.AssumedSystemReq", + # Stakeholder Requirements (stkh_req) have no TRLC representation of their + # own. When used as srcs for assumed_system_requirements(), they are the + # TRLC-level stand-in for the wider operational/system context, so they + # are converted 1:1 into AssumedSystemReq records (same TRLC type as + # above, just sourced from the existing stkh_req directive instead of a + # duplicated one). + "stkh_req": "ScoreReq.AssumedSystemReq", # Feature Requirements "feat_req": "ScoreReq.FeatReq", # Component Requirements @@ -83,6 +90,7 @@ _RE_MARKUP = re.compile(r"\*\*?(.*?)\*\*?") _RE_DIRECTIVE = re.compile(r"^\.\.\s+([\w]+)::\s*(.*)") _RE_FIELD = re.compile(r"^\s+:([\w]+):\s*(.*)") # noqa: E501 +_RE_REF = re.compile(r"^(?P[\w]+)(?:\[version==(?P\d+)\])?$") _TRLC_HEADER = """\ /******************************************************************************** @@ -140,14 +148,43 @@ def _collect_refs(fields: dict[str, str]) -> list[str]: 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.""" +def _split_ref(ref: str) -> tuple[str, str]: + """Split a raw reference token into (id, version). + + Accepts either a bare id (e.g. "foo") or an id with an explicit + ``[version==N]`` qualifier (e.g. "foo[version==2]"), as written in + ``:derived_from:``/``:satisfies:`` RST fields. The version defaults to + ``_DEFAULT_VERSION`` ("1") when no qualifier is present. Falls back to + treating the whole token as the id (version 1) if it doesn't match the + expected pattern, so malformed input degrades gracefully instead of + crashing the conversion. + """ + m = _RE_REF.match(ref) + if not m: + return ref, _DEFAULT_VERSION + return m.group("id"), m.group("version") or _DEFAULT_VERSION + + +def parse_directives( + content: str, only_types: set[str] | None = None +) -> list[dict[str, Any]]: + """Parse supported requirement directives from RST content. + + Args: + content: Raw RST source text. + only_types: If given, restrict parsing to these directive names + (e.g. {"aou_req"}), even if other supported directive types are + also present in the file (e.g. a shared file also containing + comp_req directives, converted separately by another rule). + Defaults to all directives known to DIRECTIVE_TO_TRLC. + """ + allowed = only_types if only_types is not None else set(DIRECTIVE_TO_TRLC) 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: + if not m or m.group(1) not in allowed: i += 1 continue @@ -187,7 +224,9 @@ def render_trlc(directives: list[dict[str, Any]], package: str, ref_package: str refs = _collect_refs(fields) if refs: - ref_list = ", ".join(f"{ref_package}.{r}@1" for r in refs) + ref_list = ", ".join( + "{}.{}@{}".format(ref_package, *_split_ref(r)) for r in refs + ) lines_out.append(f" derived_from = [{ref_list}]") if trlc_type in _ASSUMED_SYSTEM_REQ_TYPES: @@ -206,10 +245,11 @@ def convert( *, package: str | None = None, ref_package: str | None = None, + only_types: set[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")) + directives = parse_directives(input_path.read_text(encoding="utf-8"), only_types=only_types) if not directives: logging.warning("no supported requirement directives found in %s", input_path) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -226,6 +266,12 @@ def convert( p.add_argument("--output-dir", type=Path, required=True) p.add_argument("--package", default=None) p.add_argument("--ref-package", default=None) + p.add_argument( + "--only-types", + default=None, + help="Comma-separated list of directive names to convert, e.g. " + "'aou_req'. Defaults to all supported directive types.", + ) p.add_argument( "--log-level", choices=["error", "warn", "info", "debug"], @@ -238,5 +284,8 @@ def convert( 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) + only_types = {t.strip() for t in args.only_types.split(",") if t.strip()} if args.only_types else None + record_count = convert( + args.input_file, output_file, package=args.package, ref_package=args.ref_package, only_types=only_types + ) logging.info("%s -> %s (%d record(s))", args.input_file, output_file, record_count) diff --git a/bazel/rules/rules_score/test/rst_to_trlc_test.py b/bazel/rules/rules_score/test/rst_to_trlc_test.py index 2b6da09d..3f07fed7 100644 --- a/bazel/rules/rules_score/test/rst_to_trlc_test.py +++ b/bazel/rules/rules_score/test/rst_to_trlc_test.py @@ -396,6 +396,22 @@ def test_feat_req_imports_ref_package_when_refs_present(self): out = render_trlc(items, "FeatPkg", "AsrPkg") self.assertIn("import AsrPkg", out) + def test_feat_req_derived_from_honors_explicit_version(self): + items = self._single( + "feat_req", {"derived_from": "asr_req__test__001[version==2]"} + ) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertIn("AsrPkg.asr_req__test__001@2", out) + + def test_feat_req_derived_from_mixed_explicit_and_default_version(self): + items = self._single( + "feat_req", + {"derived_from": "asr_req__a[version==2], asr_req__b"}, + ) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertIn("AsrPkg.asr_req__a@2", out) + self.assertIn("AsrPkg.asr_req__b@1", out) + # --- CompReq --- def test_comp_req_produces_comp_req_type(self): diff --git a/bazel/rules/rules_score/test/test_fmea_assembler.py b/bazel/rules/rules_score/test/test_fmea_assembler.py index db98ace4..8cb2636a 100644 --- a/bazel/rules/rules_score/test/test_fmea_assembler.py +++ b/bazel/rules/rules_score/test/test_fmea_assembler.py @@ -67,14 +67,14 @@ def _objs(): "FM_A", "FailureMode", { - "guideword": "LossOfFunction", + "guidewords": ["LossOfFunction"], "safety": "B", "interface": "Lib.Api", "failureeffect": "world ends", "description": "fm a description", }, ), - "Lib.FM_Orphan": _Obj("FM_Orphan", "FailureMode", {"safety": "QM", "guideword": "TooLate"}), + "Lib.FM_Orphan": _Obj("FM_Orphan", "FailureMode", {"safety": "QM", "guidewords": ["TooLate"]}), "Lib.CM_1": _Obj("CM_1", "ControlMeasure", {"safety": "B", "description": "cm one"}), "Lib.CM_Orphan": _Obj("CM_Orphan", "ControlMeasure", {"safety": "D"}), } @@ -200,7 +200,7 @@ def test_chain_missing_puml_raises_valueerror(self): package TestFmea type FailureMode { - guideword optional String + guidewords String [0 .. *] safety optional String interface optional String failureeffect optional String @@ -217,7 +217,7 @@ def test_chain_missing_puml_raises_valueerror(self): package TestFmea FailureMode FmA { - guideword = "TooLate" + guidewords = ["TooLate"] safety = "ASIL_D" interface = "Lib.Api" failureeffect = "downstream timeout"