diff --git a/bazel/rules/rules_score/private/assumed_system_requirements.bzl b/bazel/rules/rules_score/private/assumed_system_requirements.bzl index ec044f9c..2ebcf378 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. @@ -87,6 +88,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/assumptions_of_use.bzl b/bazel/rules/rules_score/private/assumptions_of_use.bzl index 4eacad4b..4435e0cc 100644 --- a/bazel/rules/rules_score/private/assumptions_of_use.bzl +++ b/bazel/rules/rules_score/private/assumptions_of_use.bzl @@ -40,6 +40,7 @@ def assumptions_of_use( srcs, deps = [], ref_package = None, + package = "", lobster_config = Label("//bazel/rules/rules_score/lobster/config:aou_config"), **kwargs): """Define Assumptions of Use following S-CORE process guidelines. @@ -92,6 +93,7 @@ def assumptions_of_use( req_kind = "aou", lobster_config = lobster_config, ref_package = ref_package or "", + package = package, **kwargs ) trlc_requirements_test( diff --git a/bazel/rules/rules_score/private/component_requirements.bzl b/bazel/rules/rules_score/private/component_requirements.bzl index fd138eff..d5b27cf8 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. @@ -86,6 +87,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..abe09d3f 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. @@ -86,6 +87,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 49735d67..c80cec6c 100644 --- a/bazel/rules/rules_score/private/requirements.bzl +++ b/bazel/rules/rules_score/private/requirements.bzl @@ -207,6 +207,7 @@ def score_requirements_rule( deps = [], spec = [], ref_package = "", + package = "", **kwargs): """Macro wrapper around _score_requirements_rule with RST support. @@ -226,6 +227,9 @@ 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: TRLC package name to use for any generated .trlc file(s) when + converting RST sources. Defaults to the input file's stem (e.g. + "index.rst" -> "Index") if left empty. Returns: List of resolved labels corresponding to srcs (after any .rst-to-.trlc @@ -244,6 +248,7 @@ def score_requirements_rule( name = gen_name, srcs = [src], ref_package = ref_package, + package = package, ) 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..0a7f8f11 100644 --- a/bazel/rules/rules_score/private/rst_to_trlc.bzl +++ b/bazel/rules/rules_score/private/rst_to_trlc.bzl @@ -62,8 +62,8 @@ def rst_srcs_to_trlc(name, srcs, deps = [], ref_package = ""): 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) + for i, src in enumerate(ctx.files.srcs): + out = ctx.actions.declare_file(ctx.label.name + "/" + str(i) + "/" + src.basename.removesuffix(".rst") + ".trlc") outs.append(out) args = ctx.actions.args() diff --git a/bazel/rules/rules_score/src/rst_to_trlc.py b/bazel/rules/rules_score/src/rst_to_trlc.py index 59e429d3..44946847 100644 --- a/bazel/rules/rules_score/src/rst_to_trlc.py +++ b/bazel/rules/rules_score/src/rst_to_trlc.py @@ -83,6 +83,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_NEEDS_FILTER = re.compile(r"(\s*\[[^\[\]]*\])+\s*$") _TRLC_HEADER = """\ /******************************************************************************** @@ -135,9 +136,40 @@ def _escape(text: str) -> str: return text.replace("\\", "\\\\").replace('"', '\\"') +def _split_top_level(value: str, sep: str = ",") -> list[str]: + """Split on ``sep``, ignoring occurrences nested inside ``[...]`` brackets.""" + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in value: + if ch == "[": + depth += 1 + elif ch == "]": + depth = max(depth - 1, 0) + if ch == sep and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + return parts + + 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()] + """Extract cross-reference IDs from relationship fields. + + Strips trailing sphinx-needs filter expressions (E.g. [version==1]), + which aren't valid TRLC reference syntax. + """ + refs: list[str] = [] + for k in _REF_FIELDS: + if k not in fields: + continue + for r in _split_top_level(fields[k]): + ref = _RE_NEEDS_FILTER.sub("", r.strip()).strip() + if ref: + refs.append(ref) + return refs def parse_directives(content: str) -> list[dict[str, Any]]: @@ -154,6 +186,14 @@ def parse_directives(content: str) -> list[dict[str, Any]]: directive, title = m.group(1), m.group(2).strip() i += 1 + # RST directive arguments may wrap onto continuation lines (docutils + # allows this); consume any such lines here before the field list, + # otherwise they get mis-parsed as body text and the real :id: (and + # other fields) on the following lines are missed entirely. + while i < len(lines) and lines[i].strip() and lines[i][0].isspace() and not _RE_FIELD.match(lines[i]): + title += " " + lines[i].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() diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 11d17d57..a7a50ffe 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -32,6 +32,7 @@ load( "unit_design", ) load("@score_tooling//bazel/rules/rules_score:sphinx_toolchain.bzl", "score_sphinx_toolchain") +load("@score_tooling//bazel/rules/rules_score/private:rst_to_trlc.bzl", "rst_to_trlc") load("@score_tooling//cpp/libclang:libclang_toolchain.bzl", "libclang_toolchain") load("@trlc//:trlc.bzl", "trlc_requirements", "trlc_requirements_test", "trlc_specification") load( @@ -71,6 +72,12 @@ load( "feat_req_multi_spec_provider_test", "requirements_multi_spec_test_suite", ) +load( + ":requirements_package_test.bzl", + "asr_package_forwarded_test", + "requirements_package_test_suite", + "rst_to_trlc_no_collision_test", +) load( ":requirements_rst_test.bzl", "aous_rst_provider_test", @@ -1723,6 +1730,38 @@ aous_rst_sphinx_test( requirements_rst_test_suite(name = "requirements_rst_tests") +# ============================================================================ +# package attr forwarding / rst_to_trlc output-path collision tests +# ============================================================================ + +# Fixture: package attr must forward to the generated rst_to_trlc target. +assumed_system_requirements( + name = "asr_custom_package", + srcs = ["fixtures/rst_requirements/assumed_system_requirements.rst"], + package = "CustomAsrPackage", +) + +asr_package_forwarded_test( + name = "asr_package_forwarded_test", + target_under_test = ":_asr_custom_package_rst_gen_0", +) + +# Fixture: same-basename .rst srcs in one target must not collide. +rst_to_trlc( + name = "rst_collision_fixture", + srcs = [ + "fixtures/rst_collision/a/req.rst", + "fixtures/rst_collision/b/req.rst", + ], +) + +rst_to_trlc_no_collision_test( + name = "rst_to_trlc_no_collision_test", + target_under_test = ":rst_collision_fixture", +) + +requirements_package_test_suite(name = "requirements_package_tests") + # ============================================================================ # Multi-spec Requirements Tests # ============================================================================ @@ -1884,6 +1923,7 @@ test_suite( tests = [ ":lobster_config_tests", ":requirements_multi_spec_tests", + ":requirements_package_tests", ":requirements_rst_tests", ":seooc_tests", ":sphinx_module_tests", diff --git a/bazel/rules/rules_score/test/fixtures/rst_collision/a/req.rst b/bazel/rules/rules_score/test/fixtures/rst_collision/a/req.rst new file mode 100644 index 00000000..f60cca25 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_collision/a/req.rst @@ -0,0 +1,22 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Collision Fixture A +==================== + +.. comp_req:: From A + :id: comp_req__collision_test__a + :safety: QM + + Requirement sourced from a/req.rst. diff --git a/bazel/rules/rules_score/test/fixtures/rst_collision/b/req.rst b/bazel/rules/rules_score/test/fixtures/rst_collision/b/req.rst new file mode 100644 index 00000000..9be883c6 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/rst_collision/b/req.rst @@ -0,0 +1,22 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Collision Fixture B +==================== + +.. comp_req:: From B + :id: comp_req__collision_test__b + :safety: QM + + Requirement sourced from b/req.rst. diff --git a/bazel/rules/rules_score/test/requirements_package_test.bzl b/bazel/rules/rules_score/test/requirements_package_test.bzl new file mode 100644 index 00000000..9f2a1f3c --- /dev/null +++ b/bazel/rules/rules_score/test/requirements_package_test.bzl @@ -0,0 +1,64 @@ +# ******************************************************************************* +# 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 the ``package`` attr and the rst_to_trlc output-path fix.""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") + +# ============================================================================ +# assumed_system_requirements(package = ...) forwards to rst_to_trlc +# ============================================================================ + +def _asr_package_forwarded_test_impl(ctx): + env = analysistest.begin(ctx) + actions = analysistest.target_actions(env) + rst_actions = [a for a in actions if a.mnemonic == "RstToTrlc"] + + asserts.true(env, len(rst_actions) == 1, "expected exactly one RstToTrlc action") + if rst_actions: + argv = rst_actions[0].argv + asserts.true(env, "--package" in argv, "expected --package in RstToTrlc argv") + asserts.equals(env, "CustomAsrPackage", argv[argv.index("--package") + 1]) + + return analysistest.end(env) + +asr_package_forwarded_test = analysistest.make(_asr_package_forwarded_test_impl) + +# ============================================================================ +# rst_to_trlc: same-basename srcs in one target must not collide +# ============================================================================ + +def _rst_to_trlc_no_collision_test_impl(ctx): + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + outs = target_under_test[DefaultInfo].files.to_list() + + asserts.equals(env, 2, len(outs)) + asserts.true(env, outs[0].path != outs[1].path, "same-basename srcs must produce distinct output paths") + + return analysistest.end(env) + +rst_to_trlc_no_collision_test = analysistest.make(_rst_to_trlc_no_collision_test_impl) + +# ============================================================================ +# Test Suite +# ============================================================================ + +def requirements_package_test_suite(name): + """Register the package-forwarding and output-collision tests.""" + native.test_suite( + name = name, + tests = [ + ":asr_package_forwarded_test", + ":rst_to_trlc_no_collision_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 index a2734741..6ed2dfa7 100644 --- a/bazel/rules/rules_score/test/rst_to_trlc_test.py +++ b/bazel/rules/rules_score/test/rst_to_trlc_test.py @@ -336,6 +336,49 @@ def test_strips_rst_bold_markup_from_body(self): self.assertNotIn("**", result[0]["body"]) self.assertIn("robust", result[0]["body"]) + def test_multi_line_title_is_joined_and_fields_still_parsed(self): + """docutils allows a directive's argument to wrap onto continuation + lines with no blank-line separator; the real field list must still + be found on the lines that follow.""" + rst = _rst( + ".. comp_req:: Logging During the C++ Static Storage", + " construction and destruction.", + " :id: comp_req__test__001", + " :safety: QM", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 1) + self.assertEqual( + result[0]["title"], + "Logging During the C++ Static Storage construction and destruction.", + ) + self.assertEqual(result[0]["fields"]["id"], "comp_req__test__001") + + def test_single_line_title_unaffected_by_continuation_handling(self): + rst = _rst( + ".. feat_req:: Single Line Title", + " :id: feat_req__test__001", + " :safety: QM", + "", + " Body.", + ) + result = parse_directives(rst) + self.assertEqual(result[0]["title"], "Single Line Title") + + def test_unindented_next_directive_not_swallowed_as_continuation(self): + rst = _rst( + ".. comp_req:: A", + ".. comp_req:: B", + " :id: comp_req__test__b", + ) + result = parse_directives(rst) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["title"], "A") + self.assertEqual(result[1]["title"], "B") + self.assertEqual(result[1]["fields"]["id"], "comp_req__test__b") + # --------------------------------------------------------------------------- # render_trlc – TRLC output for each S-CORE type @@ -396,6 +439,13 @@ 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_satisfies_that_is_only_a_filter_produces_no_derived_from(self): + """A satisfies value like "[version==1]" has no id before the filter; + it must not render a malformed "derived_from = [AsrPkg.@1]" entry.""" + items = self._single("feat_req", {"satisfies": "[version==1]"}) + out = render_trlc(items, "FeatPkg", "AsrPkg") + self.assertNotIn("derived_from", out) + # --- CompReq --- def test_comp_req_produces_comp_req_type(self): @@ -545,6 +595,48 @@ def test_mitigates_is_not_a_ref_field(self): def test_returns_empty_when_no_ref_fields(self): self.assertEqual(_collect_refs({"safety": "QM", "reqtype": "Functional"}), []) + def test_strips_trailing_needs_filter(self): + """sphinx-needs filter suffixes are not valid TRLC reference syntax.""" + self.assertEqual( + _collect_refs({"satisfies": "req_001[version==1]"}), + ["req_001"], + ) + + def test_strips_multiple_trailing_needs_filters(self): + self.assertEqual( + _collect_refs({"satisfies": "req_001[version==1][status==valid]"}), + ["req_001"], + ) + + def test_strips_needs_filter_from_each_comma_separated_ref(self): + self.assertEqual( + _collect_refs({"satisfies": "req_001[version==1], req_002[status==valid]"}), + ["req_001", "req_002"], + ) + + def test_ref_that_is_only_a_filter_expression_is_dropped(self): + """A ref field containing only a filter (no id before the bracket) + must not render as an empty derived_from entry.""" + self.assertEqual(_collect_refs({"satisfies": "[version==1]"}), []) + + def test_filter_containing_comma_is_not_split_into_two_refs(self): + """sphinx-needs filters may contain commas of their own (E.g. a + top-level ``,`` inside the filter's argument list); only commas + outside of the trailing ``[...]`` separate distinct refs.""" + self.assertEqual( + _collect_refs({"satisfies": "req_001[version==1, status==x]"}), + ["req_001"], + ) + + def test_filter_with_bracketed_list_argument_is_not_split(self): + """A ``,`` inside a nested ``[...]`` filter argument must not be + treated as a ref separator, even though the top-level filter regex + does not itself strip nested brackets.""" + self.assertEqual( + _collect_refs({"satisfies": 'req_001[tags in ["a", "b"]]'}), + ['req_001[tags in ["a", "b"]]'], + ) + # --------------------------------------------------------------------------- # _collect_body – blank-line handling consistency