diff --git a/.gitignore b/.gitignore index b4c5bb79..88eef561 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ user.bazelrc # docs build artifacts /_build* docs/ubproject.toml +docs/schemas.json # Vale - editorial style guide .vale.ini diff --git a/docs/internals/requirements/requirements.rst b/docs/internals/requirements/requirements.rst index 816646cb..70a5413a 100644 --- a/docs/internals/requirements/requirements.rst +++ b/docs/internals/requirements/requirements.rst @@ -1097,6 +1097,6 @@ Grouped Requirements .. needextend:: c.this_doc() and type == 'tool_req' and not status :status: valid -.. needextend:: "metamodel.yaml" in source_code_link +.. needextend:: source_code_link is not None and "metamodel.yaml" in source_code_link :+satisfies: tool_req__docs_metamodel :+tags: config diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 0a6c4dae..2e697c36 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -31,6 +31,7 @@ from src.extensions.score_metamodel.metamodel_types import ( ScoreNeedType as ScoreNeedType, ) +from src.extensions.score_metamodel.sn_schemas import write_sn_schemas from src.extensions.score_metamodel.yaml_parser import ( default_options as default_options, ) @@ -237,10 +238,28 @@ def setup(app: Sphinx) -> dict[str, str | bool]: # load metamodel.yaml via ruamel.yaml metamodel = load_metamodel_data() + # Sphinx-Needs 6 requires extra options as dicts: {"name": ..., "schema": ...} + # Options WITH a schema get JSON schema validation (value must be a string). + # Options WITHOUT a schema are registered but not validated. + # non_schema_options = {"source_code_link", "testlink", "codelink"} + non_schema_options = {} # currently empty → all options get schema validation + extra_options_schema = [ + {"name": opt, "schema": {"type": "string"}} + for opt in metamodel.needs_extra_options + if opt not in non_schema_options + ] + extra_options_wo_schema = [ + {"name": opt} + for opt in metamodel.needs_extra_options + if opt in non_schema_options + ] + # extra_options = [{"name": opt} for opt in metamodel.needs_extra_options] + extra_options = extra_options_schema + extra_options_wo_schema + # Assign everything to Sphinx config app.config.needs_types = metamodel.needs_types app.config.needs_extra_links = metamodel.needs_extra_links - app.config.needs_extra_options = metamodel.needs_extra_options + app.config.needs_extra_options = extra_options app.config.graph_checks = metamodel.needs_graph_check app.config.prohibited_words_checks = metamodel.prohibited_words_checks @@ -251,6 +270,11 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.config.needs_reproducible_json = True app.config.needs_json_remove_defaults = True + # Generate schemas.json from the metamodel and register it with sphinx-needs. + # This enables sphinx-needs 6 schema validation: required fields, regex + # patterns on option values, and (eventually) link target type checks. + write_sn_schemas(app, metamodel) + # sphinx-collections runs on default prio 500. # We need to populate the sphinx-collections config before that happens. # --> 499 diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 398195c7..6c6b29c8 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -124,7 +124,7 @@ needs_types: mandatory_options: # req-Id: tool_req__docs_common_attr_status status: ^(valid|draft)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_links: # req-Id: tool_req__docs_req_link_satisfies_allowed # TODO: fix once process_description is fixed @@ -252,7 +252,7 @@ needs_types: # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ # WARNING: THis will be activated again with new process release (1.1.0) - # content: ^[\s\S]+$ + # content: ^(.|[\n\r])+$ # req-Id: tool_req__docs_req_attr_rationale rationale: ^.+$ # req-Id: tool_req__docs_common_attr_security @@ -266,8 +266,8 @@ needs_types: testcovered: ^(YES|NO)$ hash: ^.*$ # req-Id: tool_req__docs_req_attr_validity_correctness - valid_from: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$ - valid_until: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$ + valid_from: ^v(0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ + valid_until: ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ tags: - requirement - requirement_excl_process @@ -286,7 +286,7 @@ needs_types: safety: ^(QM|ASIL_B)$ # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ mandatory_links: # req-Id: tool_req__docs_req_link_satisfies_allowed satisfies: stkh_req @@ -299,8 +299,8 @@ needs_types: testcovered: ^(YES|NO)$ hash: ^.*$ # req-Id: tool_req__docs_req_attr_validity_correctness - valid_from: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$ - valid_until: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$ + valid_from: ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ + valid_until: ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ optional_links: belongs_to: feat # for evaluation tags: @@ -320,7 +320,7 @@ needs_types: safety: ^(QM|ASIL_B)$ # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ mandatory_links: # req-Id: tool_req__docs_req_link_satisfies_allowed satisfies: feat_req @@ -348,7 +348,7 @@ needs_types: safety: ^(QM|ASIL_B)$ # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_links: # req-Id: tool_req__docs_req_link_satisfies_allowed # TODO: make it mandatory @@ -381,7 +381,7 @@ needs_types: safety: ^(QM|ASIL_B)$ # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_options: codelink: ^.*$ testlink: ^.*$ @@ -728,7 +728,7 @@ needs_types: failure_effect: ^.*$ sufficient: ^(yes|no)$ status: ^(valid|invalid)$ - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ mandatory_links: violates: feat_arc_sta optional_options: @@ -748,7 +748,7 @@ needs_types: sufficient: ^(yes|no)$ status: ^(valid|invalid)$ # req-Id: tool_req__docs_saf_attrs_content - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ mandatory_links: # req-Id: tool_req__docs_saf_attrs_violates violates: feat_arc_sta @@ -775,7 +775,7 @@ needs_types: sufficient: ^(yes|no)$ status: ^(valid|invalid)$ # req-Id: tool_req__docs_saf_attrs_content - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_options: # req-Id: tool_req__docs_saf_attrs_mitigation_issue mitigation_issue: ^https://github.com/.*$ @@ -803,7 +803,7 @@ needs_types: sufficient: ^(yes|no)$ status: ^(valid|invalid)$ # req-Id: tool_req__docs_saf_attrs_content - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_options: # req-Id: tool_req__docs_saf_attrs_mitigation_issue mitigation_issue: ^https://github.com/.*$ @@ -830,7 +830,7 @@ needs_types: sufficient: ^(yes|no)$ status: ^(valid|invalid)$ # req-Id: tool_req__docs_saf_attrs_content - content: ^[\s\S]+$ + content: ^(.|[\n\r])+$ optional_options: # req-Id: tool_req__docs_saf_attrs_mitigation_issue mitigation_issue: ^https://github.com/.*$ @@ -971,6 +971,12 @@ needs_extra_links: partially_verifies: incoming: partially_verified_by outgoing: partially_verifies + + # Decision Records + affects: + incoming: affected by + outgoing: affects + ############################################################## # Graph Checks # The graph checks focus on the relation of the needs and their attributes. diff --git a/src/extensions/score_metamodel/sn_schemas.py b/src/extensions/score_metamodel/sn_schemas.py new file mode 100644 index 00000000..0b81c992 --- /dev/null +++ b/src/extensions/score_metamodel/sn_schemas.py @@ -0,0 +1,233 @@ +"""Transforms the YAML metamodel into sphinx-needs JSON schema definitions. + +Reads need types from the parsed metamodel (MetaModelData) and generates a +``schemas.json`` file that sphinx-needs uses to validate each need against +the S-CORE metamodel rules (required fields, regex patterns, link constraints). + +Schema structure per need type (sphinx-needs schema format): + - ``select`` : matches needs by their ``type`` field + - ``validate.local`` : validates the need's own properties (patterns, required) + - ``validate.network`` : validates properties of linked needs (NOT YET ACTIVE) +""" + +import json +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx_needs import logging + +from src.extensions.score_metamodel.yaml_parser import MetaModelData + +# Fields whose values are lists in sphinx-needs (e.g. tags: ["safety", "security"]). +# These need an "array of strings" JSON schema instead of a plain "string" schema. +SN_ARRAY_FIELDS = { + "tags", + "sections", +} + +# Fields to skip during schema generation. +IGNORE_FIELDS = { + "content", # not yet available in ubCode +} + +LOGGER = logging.get_logger(__name__) + + +def write_sn_schemas(app: Sphinx, metamodel: MetaModelData) -> None: + """Build sphinx-needs schema definitions from the metamodel and write to JSON. + + For every need type that has at least one constraint (mandatory/optional + fields or links), a schema entry is created with: + + 1. A **selector** that matches needs whose ``type`` equals the directive name. + 2. A **local validator** containing: + - ``required`` list for mandatory fields/links. + - ``properties`` with regex ``pattern`` constraints for field values. + - ``minItems: 1`` for mandatory links (must have at least one target). + 3. A **network validator** (currently disabled) that would check that + linked needs have the expected ``type``. + + The resulting JSON is written to ``/schemas.json`` and registered + with sphinx-needs via ``config.needs_schema_definitions_from_json``. + """ + config: Config = app.config + schemas = [] + schema_definitions = {"schemas": schemas} + + for need_type in metamodel.needs_types: + # Extract the four constraint categories from the metamodel YAML + mandatory_fields = need_type.get("mandatory_options", {}) + optional_fields = need_type.get("optional_options", {}) + mandatory_links = need_type.get("mandatory_links", {}) + optional_links = need_type.get("optional_links", {}) + + # Skip need types that have no constraints at all + if not ( + mandatory_fields or optional_fields or mandatory_links or optional_links + ): + continue + + # --- Classify link values as regex patterns vs. target type names --- + # In the metamodel YAML, a link value can be either: + # - A regex (starts with "^"), e.g. "^logic_arc_int(_op)*__.+$" + # → validated locally (the link ID must match the pattern) + # - A plain type name, e.g. "comp" + # → validated via network (the linked need must have that type) + # Multiple values are comma-separated, e.g. "comp, sw_unit" + mandatory_links_regexes = {} + mandatory_links_targets = {} + optional_links_regexes = {} + optional_links_targets = {} + value: str + field: str + for field, value in mandatory_links.items(): + link_values = [v.strip() for v in value.split(",")] + for link_value in link_values: + if link_value.startswith("^"): + if field in mandatory_links_regexes: + LOGGER.error( + "Multiple regex patterns for mandatory link field " + f"'{field}' in need type '{type_name}'. " + "Only the first one will be used in the schema." + ) + mandatory_links_regexes[field] = link_value + else: + mandatory_links_targets[field] = link_value + + for field, value in optional_links.items(): + link_values = [v.strip() for v in value.split(",")] + for link_value in link_values: + if link_value.startswith("^"): + if field in optional_links_regexes: + LOGGER.error( + "Multiple regex patterns for optional link field " + f"'{field}' in need type '{type_name}'. " + "Only the first one will be used in the schema." + ) + optional_links_regexes[field] = link_value + else: + optional_links_targets[field] = link_value + + # --- Build the schema entry for this need type --- + type_schema = { + "id": f"need-type-{need_type['directive']}", + "severity": "violation", + "message": "Need does not conform to S-CORE metamodel", + } + type_name = need_type["directive"] + + # Selector: only apply this schema to needs with matching type + selector = { + "properties": {"type": {"const": type_name}}, + "required": ["type"], + } + type_schema["select"] = selector + + # --- Local validation (the need's own properties) --- + type_schema["validate"] = {} + validator_local = { + "properties": {}, + "required": [], + # "unevaluatedProperties": False, + } + + # Mandatory fields: must be present AND match the regex pattern + for field, pattern in mandatory_fields.items(): + if field in IGNORE_FIELDS: + continue + validator_local["required"].append(field) + validator_local["properties"][field] = get_field_pattern_schema( + field, pattern + ) + + # Optional fields: if present, must match the regex pattern + for field, pattern in optional_fields.items(): + if field in IGNORE_FIELDS: + continue + validator_local["properties"][field] = get_field_pattern_schema( + field, pattern + ) + + # Mandatory links (regex): must have at least one entry + # TODO: regex pattern matching on link IDs is not yet enabled + for field, pattern in mandatory_links_regexes.items(): + validator_local["properties"][field] = { + "type": "array", + "minItems": 1, + } + validator_local["required"].append(field) + # validator_local["properties"][field] = get_array_pattern_schema(pattern) + + # Optional links (regex): allowed but not required + # TODO: regex pattern matching on link IDs is not yet enabled + for field, pattern in optional_links_regexes.items(): + validator_local["properties"][field] = { + "type": "array", + } + # validator_local["properties"][field] = get_array_pattern_schema(pattern) + + type_schema["validate"]["local"] = validator_local + + # --- Network validation (properties of linked needs) --- + # TODO: network validation is not yet enabled — the assignments to + # validator_network are commented out below. + validator_network = {} + for field, target_type in mandatory_links_targets.items(): + link_validator = { + "items": { + "local": { + "properties": {"type": {"type": "string", "const": target_type}} + } + }, + } + # validator_network[field] = link_validator + for field, target_type in optional_links_targets.items(): + link_validator = { + "items": { + "local": { + "properties": {"type": {"type": "string", "const": target_type}} + } + }, + } + # validator_network[field] = link_validator + if validator_network: + type_schema["validate"]["network"] = validator_network + + schemas.append(type_schema) + + # Write the complete schema definitions to a JSON file in confdir + schemas_output_path = Path(app.confdir) / "schemas.json" + with open(schemas_output_path, "w", encoding="utf-8") as f: + json.dump(schema_definitions, f, indent=2, ensure_ascii=False) + + # Tell sphinx-needs to load the schema from the JSON file + config.needs_schema_definitions_from_json = "schemas.json" + # config.needs_schema_definitions = schema_definitions + + +def get_field_pattern_schema(field: str, pattern: str): + """Return the appropriate JSON schema for a field's regex pattern. + + Array-valued fields (like ``tags``) get an array-of-strings schema; + scalar fields get a plain string schema. + """ + if field in SN_ARRAY_FIELDS: + return get_array_pattern_schema(pattern) + return get_pattern_schema(pattern) + + +def get_pattern_schema(pattern: str): + """Return a JSON schema that validates a string against a regex pattern.""" + return { + "type": "string", + "pattern": pattern, + } + + +def get_array_pattern_schema(pattern: str): + """Return a JSON schema that validates an array where each item matches a regex.""" + return { + "type": "array", + "items": get_pattern_schema(pattern), + } diff --git a/src/extensions/score_metamodel/tests/test_metamodel_load.py b/src/extensions/score_metamodel/tests/test_metamodel_load.py index 3cb67965..72568592 100644 --- a/src/extensions/score_metamodel/tests/test_metamodel_load.py +++ b/src/extensions/score_metamodel/tests/test_metamodel_load.py @@ -40,8 +40,8 @@ def test_load_metamodel_data(): assert result.needs_types[0].get("color") == "blue" assert result.needs_types[0].get("style") == "bold" assert result.needs_types[0]["mandatory_options"] == { - # default id pattern: prefix + digits, lowercase letters and underscores - "id": "^T1[0-9a-z_]+$", + # default id pattern: prefix + digits, letters and underscores + "id": "^T1[0-9a-zA-Z_]+$", "opt1": "value1", } assert result.needs_types[0]["optional_options"] == { diff --git a/src/extensions/score_metamodel/yaml_parser.py b/src/extensions/score_metamodel/yaml_parser.py index 64916a90..8c83b4e5 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -119,7 +119,7 @@ def _parse_need_type( # Ensure ID regex is set if "id" not in t["mandatory_options"]: prefix = t["prefix"] - t["mandatory_options"]["id"] = f"^{prefix}[0-9a-z_]+$" + t["mandatory_options"]["id"] = f"^{prefix}[0-9a-zA-Z_]+$" if "color" in yaml_data: t["color"] = yaml_data["color"] diff --git a/src/extensions/score_source_code_linker/__init__.py b/src/extensions/score_source_code_linker/__init__.py index 094ebf4a..6e5e07b4 100644 --- a/src/extensions/score_source_code_linker/__init__.py +++ b/src/extensions/score_source_code_linker/__init__.py @@ -375,18 +375,25 @@ def inject_links_into_needs(app: Sphinx, env: BuildEnvironment) -> None: need_as_dict = cast(dict[str, object], need) - need_as_dict["source_code_link"] = ", ".join( - f"{get_github_link(n)}<>{n.file}:{n.line}" - for n in source_code_links.links.CodeLinks - ) - need_as_dict["testlink"] = ", ".join( - f"{get_github_link(n)}<>{n.name}" for n in source_code_links.links.TestLinks - ) + modified_need = False + if source_code_links.links.CodeLinks: + modified_need = True + need_as_dict["source_code_link"] = ", ".join( + f"{get_github_link(n)}<>{n.file}:{n.line}" + for n in source_code_links.links.CodeLinks + ) + if source_code_links.links.TestLinks: + modified_need = True + need_as_dict["testlink"] = ", ".join( + f"{get_github_link(n)}<>{n.name}" + for n in source_code_links.links.TestLinks + ) - # NOTE: Removing & adding the need is important to make sure - # the needs gets 're-evaluated'. - Needs_Data.remove_need(need["id"]) - Needs_Data.add_need(need) + if modified_need: + # NOTE: Removing & adding the need is important to make sure + # the needs gets 're-evaluated'. + Needs_Data.remove_need(need["id"]) + Needs_Data.add_need(need) # ╭──────────────────────────────────────╮ diff --git a/src/extensions/score_sync_toml/__init__.py b/src/extensions/score_sync_toml/__init__.py index 79ebfb7a..72e598e6 100644 --- a/src/extensions/score_sync_toml/__init__.py +++ b/src/extensions/score_sync_toml/__init__.py @@ -59,6 +59,12 @@ def setup(app: Sphinx) -> dict[str, str | bool]: ] # TODO remove the suppress_warnings once fixed + app.config.needscfg_exclude_vars = [ + "needs_from_toml", + "needs_from_toml_table", + # "needs_schema_definitions_from_json", + ] + return { "version": "0.1", "parallel_read_safe": True,