From f768eba1baffbcd69d301aca05da7813e1f974da Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:27:01 +0200 Subject: [PATCH 1/5] WIP: Adding needextends forbidden options check --- .../score_metamodel/checks/check_options.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index 1deec28e9..c76ba8556 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -22,6 +22,7 @@ from score_metamodel.metamodel_types import AllowedLinksType from sphinx.application import Sphinx from sphinx_needs.need_item import NeedItem +from sphinx_needs.data import SphinxNeedsData def get_need_type(needs_types: list[ScoreNeedType], directive: str) -> ScoreNeedType: @@ -291,3 +292,25 @@ def check_validity_consistency( f"valid_from ({valid_from}) >= valid_until ({valid_until})." ) log.warning_for_need(need, msg) + + +@local_check +def check_needextends_forbidden_options(app: Sphinx, need: NeedItem, log: CheckLogger): + extends_data = list(SphinxNeedsData(app.env).get_or_create_extends().values()) + dissallowed: set[str] = {"status", "safety", "security"} + for needsextends in extends_data: + location = f"{needsextends['docname']}:{needsextends['lineno']}" + modifications = needsextends["modifications"] + for option, _, _ in modifications: + if option in dissallowed: + log.warning( + f"Needextend in document: {needsextends['docname']} modifies {option} which is not allowed", + location, + ) + break + # print(f"{option=}, {extend_type.name=}, {value.value=}") + break + break + # # {'needextend-internals/requirements/requirements-0': {'docname': 'internals/requirements/requirements', 'lineno': 1196, 'target_id': 'needextend-internals/requirements/requirements-0', 'filter': "c.this_doc() and type == 'tool_req'", 'filter_is_id': False, 'modifications': [('security', , FieldLiteralValue(value='NO')), ('safety', , FieldLiteralValue(value='ASIL_B'))], 'list_modifications': [], 'strict': False}, 'needextend-internals/requirements/requirements-1': {'docname': 'internals/requirements/requirements', 'lineno': 1200, 'target_id': 'needextend-internals/requirements/requirements-1', 'filter': "c.this_doc() and type == 'tool_req' and not status", 'filter_is_id': False, 'modifications': [('status', , FieldLiteralValue(value='valid'))], 'list_modifications': [], 'strict': False}} + # print(extends_data) + pass From caf57fd9f4e80f7dcecb7ea553dc5f559f4639db Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:46:24 +0200 Subject: [PATCH 2/5] Feat: Enable unmutable options in needextends check --- src/extensions/score_metamodel/__init__.py | 2 ++ src/extensions/score_metamodel/checks/check_options.py | 9 +-------- src/extensions/score_metamodel/metamodel.yaml | 6 ++++++ src/extensions/score_metamodel/yaml_parser.py | 7 +++++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 557e7b974..3dd0c7726 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -294,6 +294,7 @@ def postprocess_need_links(needs_types_list: list[ScoreNeedType]): def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value("external_needs_source", "", rebuild="env") app.add_config_value("score_metamodel_yaml", "", rebuild="env") + app.add_config_value("unmutable_options", [], rebuild="env") config_setdefault(app.config, "needs_id_required", True) config_setdefault(app.config, "needs_id_regex", "^[A-Za-z0-9_-]{6,}") @@ -308,6 +309,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.config.needs_fields.update(metamodel.needs_fields) app.config.graph_checks = metamodel.needs_graph_check app.config.prohibited_words_checks = metamodel.prohibited_words_checks + app.config.unmutable_options = metamodel.unmutable_options # app.config.stop_words = metamodel["stop_words"] # app.config.weak_words = metamodel["weak_words"] diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index c76ba8556..c5a4efcd8 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -297,7 +297,7 @@ def check_validity_consistency( @local_check def check_needextends_forbidden_options(app: Sphinx, need: NeedItem, log: CheckLogger): extends_data = list(SphinxNeedsData(app.env).get_or_create_extends().values()) - dissallowed: set[str] = {"status", "safety", "security"} + dissallowed: list[str] = app.config.unmutable_options for needsextends in extends_data: location = f"{needsextends['docname']}:{needsextends['lineno']}" modifications = needsextends["modifications"] @@ -307,10 +307,3 @@ def check_needextends_forbidden_options(app: Sphinx, need: NeedItem, log: CheckL f"Needextend in document: {needsextends['docname']} modifies {option} which is not allowed", location, ) - break - # print(f"{option=}, {extend_type.name=}, {value.value=}") - break - break - # # {'needextend-internals/requirements/requirements-0': {'docname': 'internals/requirements/requirements', 'lineno': 1196, 'target_id': 'needextend-internals/requirements/requirements-0', 'filter': "c.this_doc() and type == 'tool_req'", 'filter_is_id': False, 'modifications': [('security', , FieldLiteralValue(value='NO')), ('safety', , FieldLiteralValue(value='ASIL_B'))], 'list_modifications': [], 'strict': False}, 'needextend-internals/requirements/requirements-1': {'docname': 'internals/requirements/requirements', 'lineno': 1200, 'target_id': 'needextend-internals/requirements/requirements-1', 'filter': "c.this_doc() and type == 'tool_req' and not status", 'filter_is_id': False, 'modifications': [('status', , FieldLiteralValue(value='valid'))], 'list_modifications': [], 'strict': False}} - # print(extends_data) - pass diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 7d82bde3b..bdbcc9a4f 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -53,6 +53,12 @@ prohibited_words_checks: - thing - absolutely +unmutable_options: + options: + - safety + - security + - status + needs_types: # See metamodel.md for how to define a new need type diff --git a/src/extensions/score_metamodel/yaml_parser.py b/src/extensions/score_metamodel/yaml_parser.py index 454a502c0..d43ce0b6e 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -15,6 +15,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, cast +from itertools import chain from ruamel.yaml import YAML from sphinx_needs import logging @@ -32,6 +33,7 @@ class MetaModelData: needs_types: list[ScoreNeedType] needs_links: dict[str, dict[str, str]] needs_fields: dict[str, dict[str, Any]] + unmutable_options: list[str] prohibited_words_checks: list[ProhibitedWordCheck] needs_graph_check: dict[str, object] @@ -49,6 +51,10 @@ def _parse_prohibited_words( ] +def _parse_unmutable_options(option_dict: dict[str, list[str]]) -> list[str]: + return list(chain(*option_dict.values())) + + def default_options(): """ Helper function to get a list of all default options defined by @@ -216,6 +222,7 @@ def load_metamodel_data(yaml_path: Path | None = None) -> MetaModelData: needs_types=list(needs_types.values()), needs_links=_parse_links(data.get("needs_extra_links", {})), needs_fields=_collect_all_custom_options(needs_types), + unmutable_options=_parse_unmutable_options(data.get("unmutable_options", {})), prohibited_words_checks=prohibited_words_checks, needs_graph_check=data.get("graph_checks", {}), ) From 39dc8b42999ebdd5ffab78a69db4c927af876515 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:49:13 +0200 Subject: [PATCH 3/5] Chore: Formatting --- src/extensions/score_metamodel/checks/check_options.py | 2 +- src/extensions/score_metamodel/yaml_parser.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index c5a4efcd8..685c57a52 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -21,8 +21,8 @@ ) from score_metamodel.metamodel_types import AllowedLinksType from sphinx.application import Sphinx -from sphinx_needs.need_item import NeedItem from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.need_item import NeedItem def get_need_type(needs_types: list[ScoreNeedType], directive: str) -> ScoreNeedType: diff --git a/src/extensions/score_metamodel/yaml_parser.py b/src/extensions/score_metamodel/yaml_parser.py index d43ce0b6e..f17db9074 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -13,9 +13,9 @@ """Functionality related to reading in the SCORE metamodel.yaml""" from dataclasses import dataclass +from itertools import chain from pathlib import Path from typing import Any, cast -from itertools import chain from ruamel.yaml import YAML from sphinx_needs import logging From dfb0ae7c5336d30d0fcf4ff926c1b433ebf95d5a Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 22 May 2026 17:32:30 +0200 Subject: [PATCH 4/5] WIP: Testing. --- .../checks/check_needs_extends.py | 220 ++++++++++++++++++ .../score_metamodel/checks/check_options.py | 26 +-- 2 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/extensions/score_metamodel/checks/check_needs_extends.py diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py new file mode 100644 index 000000000..48e7e74ae --- /dev/null +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -0,0 +1,220 @@ +from __future__ import annotations +from docutils import nodes + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable +from sphinx_needs.exceptions import NeedsInvalidFilter +from sphinx_needs.filter_common import filter_needs_mutable +from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.need_item import NeedModification +from sphinx_needs.needs_schema import ( + FieldFunctionArray, + FieldLiteralValue, + LinksFunctionArray, + LinksLiteralValue, +) +import sphinx_needs.directives.need + + +class Needextend(nodes.General, nodes.Element): + pass + + +logger = get_logger(__name__) + + + +def score_extend_needs_data_func( + all_needs: NeedsMutable, + extends: dict[str, NeedsExtendType], + needs_config: NeedsSphinxConfig, +) -> None: + """Use data gathered from needextend directives to modify fields of existing needs.""" + + # Sort by (docname, lineno) to ensure deterministic ordering, + # regardless of parallel build worker completion order. + sorted_extends = sorted(extends.values(), key=lambda x: (x["docname"], x["lineno"])) + + current_needextend: NeedsExtendType + raise RuntimeError("TESING replacement func") + for current_needextend in sorted_extends: + need_filter = current_needextend["filter"] + location = (current_needextend["docname"], current_needextend["lineno"]) + if current_needextend["filter_is_id"]: + try: + found_needs = [all_needs[need_filter]] + except KeyError: + error = f"Provided id {need_filter!r} for needextend does not exist." + if current_needextend["strict"]: + raise NeedsInvalidFilter(error) + else: + log_warning(logger, error, "needextend", location=location) + continue + else: + try: + found_needs = filter_needs_mutable( + all_needs, + needs_config, + need_filter, + location=location, + origin_docname=current_needextend["docname"], + ) + except Exception as e: + log_warning( + logger, + f"Invalid filter {need_filter!r}: {e}", + "needextend", + location=location, + ) + continue + for found_need in found_needs: + # Work in the stored needs, not on the search result + need = all_needs[found_need["id"]] + need.add_modification( + NeedModification( + docname=current_needextend["docname"], + lineno=current_needextend["lineno"], + ) + ) + + location = ( + current_needextend["docname"], + current_needextend["lineno"], + ) + + + for option_name, etype, link_value in current_needextend[ + "list_modifications" + ]: + # append link = ok?! + # replace / remove link = nope + # set option = ok + # replace / remove option = nope + match (etype, link_value): + case (ExtendType.APPEND, LinksLiteralValue()): + if (df := need._dynamic_fields.get(option_name)) is not None: + need._dynamic_fields[option_name] = LinksFunctionArray( + (*df.value, *link_value.value) + ) + need[option_name] = [] + else: + existing = need.get_links(option_name, as_str=False) + need[option_name] = [ + *existing, + *( # keep unique + v for v in link_value.value if v not in existing + ), + ] + case (ExtendType.APPEND, LinksFunctionArray()): + if (df := need._dynamic_fields.get(option_name)) is not None: + need._dynamic_fields[option_name] = LinksFunctionArray( + ( # keep unique + *df.value, + *(v for v in link_value.value if v not in df.value), + ) + ) + need[option_name] = [] + else: + existing = need.get_links(option_name, as_str=False) + need._dynamic_fields[option_name] = LinksFunctionArray( + ( + *existing, + *( # keep unique + v for v in link_value.value if v not in existing + ), + ) + ) + need[option_name] = [] + case (ExtendType.REPLACE | ExtendType.DELETE, LinksLiteralValue()): + error_msg = ( + "Replace or Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + raise RuntimeError(f"{location}: {error_msg}") + case (ExtendType.REPLACE | ExtendType.DELETE, LinksFunctionArray()): + error_msg = ( + "Replace or Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + raise RuntimeError(f"{location}: {error_msg}") + case other_link: + raise RuntimeError( + f"Unhandled case {other_link} for {option_name!r}" + ) + + for option_name, etype, field_value in current_needextend["modifications"]: + match (etype, field_value): + case (ExtendType.APPEND, FieldLiteralValue()): + if (df := need._dynamic_fields.get(option_name)) is not None: + need._dynamic_fields[option_name] = ( + FieldFunctionArray((*df.value, *field_value.value)) + if isinstance(field_value.value, list) + else FieldFunctionArray((*df.value, field_value.value)) + ) + else: + if isinstance(field_value.value, list): + need[option_name] = [ + *need[option_name], + *field_value.value, + ] + elif isinstance(field_value.value, str): + need[option_name] = ( + need[option_name] + " " + field_value.value + if need[option_name] + else field_value.value + ) + else: + raise RuntimeError( + f"Cannot append non-string/array value {field_value.value!r} to field '{option_name}'" + ) + case (ExtendType.APPEND, FieldFunctionArray()): + if (df := need._dynamic_fields.get(option_name)) is not None: + need._dynamic_fields[option_name] = FieldFunctionArray( + (*df.value, *field_value.value) + ) + else: + if isinstance(need[option_name], list): + need._dynamic_fields[option_name] = FieldFunctionArray( + (*need[option_name], *field_value.value) + ) + elif isinstance(need[option_name], str): + need._dynamic_fields[option_name] = FieldFunctionArray( + ( + need[option_name], + *field_value.value, + ) + ) + else: + raise RuntimeError( + f"Cannot append non-string/array value {field_value.value!r} to field '{option_name}'" + ) + case (ExtendType.REPLACE | ExtendType.DELETE, None): + error_msg = ( + "Replace or Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + raise RuntimeError(f"{location}: {error_msg}") + case (ExtendType.REPLACE | ExtendType.DELETE, FieldLiteralValue()): + error_msg = ( + "Replace or Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + raise RuntimeError(f"{location}: {error_msg}") + case (ExtendType.REPLACE | ExtendType.DELETE, FieldFunctionArray()): + error_msg = ( + "Replace or Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + raise RuntimeError(f"{location}: {error_msg}") + # TODO reset need[option_name] to something sensible? + case other_field: + raise RuntimeError( + f"Unhandled case {other_field} for {option_name!r}" + ) + + +sphinx_needs.directives.need.extends_needs_data = score_extend_needs_data_func + +print("=====================================") +print("WE HAVE REPLACED THE EXTENDS FUNC") +print("=====================================") diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index 685c57a52..22c78f6ca 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -294,16 +294,16 @@ def check_validity_consistency( log.warning_for_need(need, msg) -@local_check -def check_needextends_forbidden_options(app: Sphinx, need: NeedItem, log: CheckLogger): - extends_data = list(SphinxNeedsData(app.env).get_or_create_extends().values()) - dissallowed: list[str] = app.config.unmutable_options - for needsextends in extends_data: - location = f"{needsextends['docname']}:{needsextends['lineno']}" - modifications = needsextends["modifications"] - for option, _, _ in modifications: - if option in dissallowed: - log.warning( - f"Needextend in document: {needsextends['docname']} modifies {option} which is not allowed", - location, - ) +# @local_check +# def check_needextends_forbidden_options(app: Sphinx, need: NeedItem, log: CheckLogger): +# extends_data = list(SphinxNeedsData(app.env).get_or_create_extends().values()) +# dissallowed: list[str] = app.config.unmutable_options +# for needsextends in extends_data: +# location = f"{needsextends['docname']}:{needsextends['lineno']}" +# modifications = needsextends["modifications"] +# for option, _, _ in modifications: +# if option in dissallowed: +# log.warning( +# f"Needextend in document: {needsextends['docname']} modifies {option} which is not allowed", +# location, +# ) From 87769bde5ac45e7ccf29c4fe6b01cf5b94f256b0 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 22 May 2026 17:33:07 +0200 Subject: [PATCH 5/5] WIP: testing --- .../checks/check_needs_extends.py | 21 +++++++++++++------ .../score_metamodel/checks/check_options.py | 1 - 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index 48e7e74ae..6bfd21f1c 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -1,6 +1,19 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* from __future__ import annotations -from docutils import nodes +import sphinx_needs.directives.need +from docutils import nodes from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable from sphinx_needs.exceptions import NeedsInvalidFilter @@ -13,7 +26,6 @@ LinksFunctionArray, LinksLiteralValue, ) -import sphinx_needs.directives.need class Needextend(nodes.General, nodes.Element): @@ -23,7 +35,6 @@ class Needextend(nodes.General, nodes.Element): logger = get_logger(__name__) - def score_extend_needs_data_func( all_needs: NeedsMutable, extends: dict[str, NeedsExtendType], @@ -47,8 +58,7 @@ def score_extend_needs_data_func( error = f"Provided id {need_filter!r} for needextend does not exist." if current_needextend["strict"]: raise NeedsInvalidFilter(error) - else: - log_warning(logger, error, "needextend", location=location) + log_warning(logger, error, "needextend", location=location) continue else: try: @@ -82,7 +92,6 @@ def score_extend_needs_data_func( current_needextend["lineno"], ) - for option_name, etype, link_value in current_needextend[ "list_modifications" ]: diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index 22c78f6ca..2feff3876 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -21,7 +21,6 @@ ) from score_metamodel.metamodel_types import AllowedLinksType from sphinx.application import Sphinx -from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem