From 058a104de5e463c6ed9811fd60001fc1741f44f4 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:27:01 +0200 Subject: [PATCH 01/17] 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 dfc44ba22..615042b95 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 d9f441b397da8fe0365df23b3e6df04e1ea525cc Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:46:24 +0200 Subject: [PATCH 02/17] 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 7f26c9b83..1b01b4810 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -314,6 +314,7 @@ def _clear_needs_defaults(app: Sphinx): 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,}") @@ -329,6 +330,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 615042b95..e56e4f124 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 18da85c7b..08f7c13d5 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 a33b81f4bdcc2911494928c09650998c6aebc58c Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 20 May 2026 11:49:13 +0200 Subject: [PATCH 03/17] 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 e56e4f124..842b6f94c 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 27929c2e07eb3d43268a50a8f19129995d972d66 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 22 May 2026 17:32:30 +0200 Subject: [PATCH 04/17] 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 842b6f94c..a35b9dfe8 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 a1c1ec664985b7ed3d4d63b2f483c1a0047bcb9f Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 22 May 2026 17:33:07 +0200 Subject: [PATCH 05/17] 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 a35b9dfe8..22feb8dba 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 From 31d5653c32bc1ad2c7ff15c67db2e77fffa77d28 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 27 May 2026 16:53:20 +0200 Subject: [PATCH 06/17] Feat: Add needextend checks and tests --- .../checks/check_needs_extends.py | 170 ++++-------------- .../tests/rst/options/test_need_extends.rst | 40 +++++ 2 files changed, 76 insertions(+), 134 deletions(-) create mode 100644 src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index 6bfd21f1c..e319630cb 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -13,13 +13,12 @@ from __future__ import annotations 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 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.directives.needextend import extend_needs_data as original_function from sphinx_needs.needs_schema import ( FieldFunctionArray, FieldLiteralValue, @@ -27,11 +26,6 @@ LinksLiteralValue, ) - -class Needextend(nodes.General, nodes.Element): - pass - - logger = get_logger(__name__) @@ -39,7 +33,7 @@ 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, @@ -47,10 +41,13 @@ def score_extend_needs_data_func( 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 "c.this_doc()" not in need_filter: + error_msg = "Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document" + log_warning(logger, error_msg, "needextend", location=location) + if current_needextend["filter_is_id"]: try: found_needs = [all_needs[need_filter]] @@ -78,14 +75,15 @@ def score_extend_needs_data_func( ) continue for found_need in found_needs: + if found_need["is_external"]: + log_warning( + logger, + "Error. It is not allowed to modify external needs via needextend", + "needextend", + location, + ) # 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"], @@ -95,135 +93,39 @@ def score_extend_needs_data_func( 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()): + case ( + ExtendType.REPLACE | ExtendType.DELETE, + LinksLiteralValue() | LinksFunctionArray(), + ): + # Replacing / Deleting links is not allowed 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"]: + if etype == ExtendType.DELETE: + error_msg = "Delete action is not allowed via needextends." + log_warning(logger, error_msg, "needextend", location=location) 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) + if isinstance(field_value.value, str): + error_msg = "Append action is not allowed via needextends on 'string type options'." + log_warning( + logger, error_msg, "needextend", location=location ) - 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}" - ) + case ( + ExtendType.REPLACE, + None | FieldLiteralValue() | FieldFunctionArray(), + ): + if need[option_name]: + error_msg = f"Error when extending need: {need['id']}. Replacing of options that are already set is not allowed via needextends." + log_warning( + logger, error_msg, "needextend", location=location + ) + return original_function(all_needs, extends, needs_config) -sphinx_needs.directives.need.extends_needs_data = score_extend_needs_data_func -print("=====================================") -print("WE HAVE REPLACED THE EXTENDS FUNC") -print("=====================================") +sphinx_needs.directives.need.extend_needs_data = score_extend_needs_data_func diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst new file mode 100644 index 000000000..647423ce4 --- /dev/null +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -0,0 +1,40 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + + + +.. stkh_req:: Test Req Extends 1 + :id: stkh_req__test__need_extends_1 + :status: invalid + + + + +.. Replacing of options that are already set is not allowed. +#EXPECT: Replacing of options that are already set is not allowed via needextends. + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :status: valid + + +.. We explicitly allow the replacing of options on needs that are NOT set and +.. where the need is in the current document +#EXPECT-NOT: Replacing of options + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + +# EXPECT: Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document +.. needextend:: id == 'stkh_req__test__need_extends_1' + :security: QM From e83e5a160121a4ae583844bd54c8d80a314b253c Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 10:13:28 +0200 Subject: [PATCH 07/17] WIP: Needextends --- src/extensions/score_metamodel/BUILD | 15 ++++++-- .../checks/check_needs_extends.py | 4 ++- .../tests/test_rules_file_based.py | 36 ++++++++++--------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD index 82fe3eaed..cfd560d41 100644 --- a/src/extensions/score_metamodel/BUILD +++ b/src/extensions/score_metamodel/BUILD @@ -60,9 +60,20 @@ py_library( ) score_pytest( - name = "score_metamodel_tests", + name = "unit_tests", size = "small", - srcs = glob(["tests/*.py"]), + srcs = glob( + ["tests/*.py"], + exclude = ["tests/test_rules_file_based.py"], + ), + deps = [":score_metamodel"], + pytest_config = "//:pyproject.toml", +) + +score_pytest( + name = "file_based_tests", + size = "medium", + srcs = ["tests/test_rules_file_based.py"], # All requirements already in the library so no need to have it double data = glob( [ diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index e319630cb..6433c6959 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -12,6 +12,9 @@ # ******************************************************************************* from __future__ import annotations +import os +from pathlib import Path + import sphinx_needs.directives.need from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable @@ -25,7 +28,6 @@ LinksFunctionArray, LinksLiteralValue, ) - logger = get_logger(__name__) diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index 73a65962c..e71f943ab 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -36,12 +36,12 @@ def sphinx_base_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: ### Create a temporary directory for Sphinx and copy all necessary files. base_dir: Path = tmp_path_factory.mktemp("docs") shutil.copy(RST_DIR / "conf.py", base_dir) - shutil.copytree( - DOCS_DIR / TOOLING_DIR_NAME, - base_dir / TOOLING_DIR_NAME, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("*.rst"), - ) + # shutil.copytree( + # DOCS_DIR / TOOLING_DIR_NAME, + # base_dir / TOOLING_DIR_NAME, + # dirs_exist_ok=True, + # ignore=shutil.ignore_patterns("*.rst"), + # ) return base_dir @@ -158,11 +158,7 @@ def filter_warnings_by_position( a random warning because 'test' is in the filename of 'graph/test_graph_checks.rst' """ prefix = f"{rst_data.filename}:{warning_info.lineno}: WARNING:" - return [ - warning.removeprefix(prefix) - for warning in warnings - if warning.startswith(prefix) - ] + return [warning.removeprefix(prefix) for warning in warnings if prefix in warning] def warning_matches( @@ -207,13 +203,19 @@ def test_rst_files( app.build() # Collect the warnings - raw_warnings = app.warning.getvalue().splitlines() - warnings = [strip_ansi_codes(w) for w in raw_warnings if "score_metamodel" in w] + raw_warnings: list[str] = app.warning.getvalue().splitlines() + warnings = [ + strip_ansi_codes(w) + for w in raw_warnings + if "score_metamodel" in w or "needs.needextend" in w + ] - # Enable this if you need to see errors for debugging purposes - # print( - # "\n".join(strip_ansi_codes(w) for w in raw_warnings if "score_metamodel" in w) - # ) + # ╓ ╖ + # ║ Enable this if you need to see errors for debugging ║ + # ║ purposes ║ + # ╙ ╜ + + # print("\n".join(strip_ansi_codes(w) for w in warnings)) # Check if the expected warnings are present for warning_info in rst_data.warning_infos: From 376a8d2d5d49d03ba168c2bc0bd76c245ba49024 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 15:45:20 +0200 Subject: [PATCH 08/17] Feat: Needextends wrapper to controll allowed usage --- src/extensions/score_metamodel/BUILD | 11 +++-- .../checks/check_needs_extends.py | 29 ++++++------ .../score_metamodel/tests/rst/conf.py | 4 ++ .../tests/rst/options/test_need_extends.rst | 44 ++++++++++--------- .../tests/test_rules_file_based.py | 8 +--- 5 files changed, 50 insertions(+), 46 deletions(-) diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD index cfd560d41..3ddd80e15 100644 --- a/src/extensions/score_metamodel/BUILD +++ b/src/extensions/score_metamodel/BUILD @@ -63,13 +63,16 @@ score_pytest( name = "unit_tests", size = "small", srcs = glob( - ["tests/*.py"], + [ + "tests/*.py", + ], exclude = ["tests/test_rules_file_based.py"], ), - deps = [":score_metamodel"], + data = ["tests/model/simple_model.yaml"], pytest_config = "//:pyproject.toml", + deps = [":score_metamodel"], ) - + score_pytest( name = "file_based_tests", size = "medium", @@ -81,6 +84,6 @@ score_pytest( "tests/**/*.yaml", ], ) + ["tests/rst/conf.py"], - deps = [":score_metamodel"], pytest_config = "//:pyproject.toml", + deps = [":score_metamodel"], ) diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index 6433c6959..edd686ba9 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -12,33 +12,29 @@ # ******************************************************************************* from __future__ import annotations -import os -from pathlib import Path - import sphinx_needs.directives.need from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable +from sphinx_needs.directives.needextend import extend_needs_data as original_function 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.directives.needextend import extend_needs_data as original_function from sphinx_needs.needs_schema import ( FieldFunctionArray, FieldLiteralValue, LinksFunctionArray, LinksLiteralValue, ) + logger = get_logger(__name__) -def score_extend_needs_data_func( +def score_extend_needs_data_func( # noqa: C901 all_needs: NeedsMutable, extends: dict[str, NeedsExtendType], needs_config: NeedsSphinxConfig, ): """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"])) @@ -46,9 +42,14 @@ def score_extend_needs_data_func( for current_needextend in sorted_extends: need_filter = current_needextend["filter"] location = (current_needextend["docname"], current_needextend["lineno"]) - if "c.this_doc()" not in need_filter: - error_msg = "Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document" - log_warning(logger, error_msg, "needextend", location=location) + + # ╓ ╖ + # ║ This is currently as a grace period still allowed, but ║ + # ║ will be forbiden in future releases ║ + # ╙ ╜ + # if "c.this_doc()" not in need_filter: + # error_msg = "Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document" + # log_warning(logger, error_msg, "needextend", location=location) if current_needextend["filter_is_id"]: try: @@ -56,7 +57,7 @@ def score_extend_needs_data_func( except KeyError: error = f"Provided id {need_filter!r} for needextend does not exist." if current_needextend["strict"]: - raise NeedsInvalidFilter(error) + raise NeedsInvalidFilter(error) from KeyError log_warning(logger, error, "needextend", location=location) continue else: @@ -92,9 +93,7 @@ def score_extend_needs_data_func( current_needextend["lineno"], ) - for option_name, etype, link_value in current_needextend[ - "list_modifications" - ]: + for _, etype, link_value in current_needextend["list_modifications"]: match (etype, link_value): case ( ExtendType.REPLACE | ExtendType.DELETE, @@ -104,6 +103,7 @@ def score_extend_needs_data_func( error_msg = ( "Replace or Delete action is not allowed via needextends." ) + # logger.warning_for_need(current_needextend["id"], error_msg) log_warning(logger, error_msg, "needextend", location=location) for option_name, etype, field_value in current_needextend["modifications"]: @@ -124,6 +124,7 @@ def score_extend_needs_data_func( ): if need[option_name]: error_msg = f"Error when extending need: {need['id']}. Replacing of options that are already set is not allowed via needextends." + log_warning( logger, error_msg, "needextend", location=location ) diff --git a/src/extensions/score_metamodel/tests/rst/conf.py b/src/extensions/score_metamodel/tests/rst/conf.py index e68ed77cd..180333964 100644 --- a/src/extensions/score_metamodel/tests/rst/conf.py +++ b/src/extensions/score_metamodel/tests/rst/conf.py @@ -27,3 +27,7 @@ "json_url": "https://eclipse-score.github.io/process_description/main/needs.json", } ] +# We add these suppress_warnings here to ease the load of the warnings +# In the future we might want to check if ANY warnings comes in the document +# And then ensure that we error, as this could also be parsing errors etc. +suppress_warnings = ["app.add_directive", "app.add_node", "app.add_role"] diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst index 647423ce4..2edcd9c49 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -1,40 +1,42 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* - - +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* .. stkh_req:: Test Req Extends 1 :id: stkh_req__test__need_extends_1 :status: invalid - .. Replacing of options that are already set is not allowed. + #EXPECT: Replacing of options that are already set is not allowed via needextends. .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' - :status: valid + :status: valid -.. We explicitly allow the replacing of options on needs that are NOT set and +.. We explicitly allow the replacing of options on needs that are NOT set and .. where the need is in the current document + #EXPECT-NOT: Replacing of options .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' - :safety: NO + :safety: NO + +.. This will be activated once we have activated the c.this_doc() check aswell +.. #EXPECT: Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document -# EXPECT: Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document -.. needextend:: id == 'stkh_req__test__need_extends_1' - :security: QM +.. .. needextend:: id == 'stkh_req__test__need_extends_1' +.. :security: QM diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index e71f943ab..dd466456f 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -36,12 +36,6 @@ def sphinx_base_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: ### Create a temporary directory for Sphinx and copy all necessary files. base_dir: Path = tmp_path_factory.mktemp("docs") shutil.copy(RST_DIR / "conf.py", base_dir) - # shutil.copytree( - # DOCS_DIR / TOOLING_DIR_NAME, - # base_dir / TOOLING_DIR_NAME, - # dirs_exist_ok=True, - # ignore=shutil.ignore_patterns("*.rst"), - # ) return base_dir @@ -215,7 +209,7 @@ def test_rst_files( # ║ purposes ║ # ╙ ╜ - # print("\n".join(strip_ansi_codes(w) for w in warnings)) + # print("\n".join(strip_ansi_codes(w) for w in raw_warnings)) # Check if the expected warnings are present for warning_info in rst_data.warning_infos: From 792f9a8f5dc45c08a6dedf2270825e006e1cc3d0 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 16:11:34 +0200 Subject: [PATCH 09/17] Fix: Add Documentation example for need_extend --- docs/how-to/write_docs.rst | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst index c1daaf191..112d537a6 100644 --- a/docs/how-to/write_docs.rst +++ b/docs/how-to/write_docs.rst @@ -15,6 +15,9 @@ Write Documentation =================== +Outside Documentation +^^^^^^^^^^^^^^^^^^^^^ + `Sphinx `_: the documentation generator we use. @@ -23,3 +26,46 @@ the plain-text markup language used for most source files in this project. `Sphinx-Needs `_: a Sphinx extension that models requirements, tests, tasks and other "needs" inside the docs. + + +Advanced +^^^^^^^^ + +Needextend +~~~~~~~~~~ + +Needextend allows you to extend needs that are defined in the documentaiton. +The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases. + +Allowed uscases: +- Setting an attribute or Link **IF** it is not already set in the need that is getting modified +- Appending to a list of links + +Not Allowed: +- Overwriting an attribute or Link that already is set in the need that gets modified +- Deleting an attribute or Link + +.. code-block:: rst + + .. stkh_req:: Test Req Extends 1 + :id: stkh_req__test__need_extends_1 + :status: invalid + + + # ✅ ALLOWED => The replacing of attributes on needs that are NOT set. + .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + + # ❌ NOT ALLOWED => Overwriting attributes that are already set in the need + .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :status: valid + + +For further documentation on needextends please `look here `_ + +.. note:: + + In the future we will enable a check that needextends will only modify needs in the current document. + You can ensure this by adding `c.this_doc()` to the filter string of the need. + From 80ddb3a67310b4da65fc8610c742909c4aa0dd17 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 16:12:09 +0200 Subject: [PATCH 10/17] Chore: Fix formatting --- docs/how-to/write_docs.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst index 112d537a6..c9824e54c 100644 --- a/docs/how-to/write_docs.rst +++ b/docs/how-to/write_docs.rst @@ -37,7 +37,7 @@ Needextend Needextend allows you to extend needs that are defined in the documentaiton. The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases. -Allowed uscases: +Allowed uscases: - Setting an attribute or Link **IF** it is not already set in the need that is getting modified - Appending to a list of links @@ -54,7 +54,7 @@ Not Allowed: # ✅ ALLOWED => The replacing of attributes on needs that are NOT set. .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' - :safety: NO + :safety: NO # ❌ NOT ALLOWED => Overwriting attributes that are already set in the need @@ -64,8 +64,7 @@ Not Allowed: For further documentation on needextends please `look here `_ -.. note:: +.. note:: - In the future we will enable a check that needextends will only modify needs in the current document. + In the future we will enable a check that needextends will only modify needs in the current document. You can ensure this by adding `c.this_doc()` to the filter string of the need. - From a4f1996b144d3d7b808a373e7ae46b9787038614 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 16:25:23 +0200 Subject: [PATCH 11/17] Fix: Remove old approach for needextend --- src/extensions/score_metamodel/__init__.py | 2 -- .../score_metamodel/checks/check_options.py | 15 --------------- src/extensions/score_metamodel/metamodel.yaml | 6 ------ src/extensions/score_metamodel/yaml_parser.py | 7 ------- 4 files changed, 30 deletions(-) diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 1b01b4810..7f26c9b83 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -314,7 +314,6 @@ def _clear_needs_defaults(app: Sphinx): 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,}") @@ -330,7 +329,6 @@ 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 22feb8dba..dfc44ba22 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -291,18 +291,3 @@ 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: 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, -# ) diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 08f7c13d5..18da85c7b 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -53,12 +53,6 @@ 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 f17db9074..454a502c0 100644 --- a/src/extensions/score_metamodel/yaml_parser.py +++ b/src/extensions/score_metamodel/yaml_parser.py @@ -13,7 +13,6 @@ """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 @@ -33,7 +32,6 @@ 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] @@ -51,10 +49,6 @@ 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 @@ -222,7 +216,6 @@ 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 ffa4a0e3ac28ecc9995c3799f93a5357b76c5873 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Thu, 28 May 2026 16:37:05 +0200 Subject: [PATCH 12/17] Fix: Make documentation actually lists --- docs/how-to/write_docs.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst index c9824e54c..7cbd274a9 100644 --- a/docs/how-to/write_docs.rst +++ b/docs/how-to/write_docs.rst @@ -38,12 +38,14 @@ Needextend allows you to extend needs that are defined in the documentaiton. The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases. Allowed uscases: -- Setting an attribute or Link **IF** it is not already set in the need that is getting modified -- Appending to a list of links + +* Setting an attribute or Link **IF** it is not already set in the need that is getting modified +* Appending to a list of links Not Allowed: -- Overwriting an attribute or Link that already is set in the need that gets modified -- Deleting an attribute or Link + +* Overwriting an attribute or Link that already is set in the need that gets modified +* Deleting an attribute or Link .. code-block:: rst From 0306ba6e2cbc3dfbe313afeb5794a725f752ec57 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 29 May 2026 10:45:31 +0200 Subject: [PATCH 13/17] Fix: Add further testcases Added further testcases to the rst tests --- .../tests/rst/options/test_need_extends.rst | 33 +++++++++++++++++++ .../tests/test_rules_file_based.py | 3 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst index 2edcd9c49..bd5e8987f 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -17,6 +17,15 @@ :status: invalid +.. stkh_req:: Test Req Extends 2 + :id: stkh_req__test__need_extends_abc + :status: valid + + +.. feat_req:: Test Linkage Override + :id: feat_req__test__linkage_override + :satisfies: stkh_req__test__need_extends_1 + .. Replacing of options that are already set is not allowed. @@ -35,6 +44,30 @@ :safety: NO +#EXPECT-NOT: Replacing of options + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + +# EXPECT: Replace or Delete action is not allowed via needextends. + +.. needextend:: feat_req__test__linkage_override + :satisfies: stkh_req__test__need_extends_abc + + +#EXPECT: Replace or Delete action is not allowed via needextends. + +.. needextend:: id == 'stkh_req__test__need_extends_1' + :-safety: + + +#EXPECT: Append action is not allowed via needextends on 'string type options' + +.. needextend:: id == 'stkh_req__test__need_extends_1' + :+safety: YES + + .. This will be activated once we have activated the c.this_doc() check aswell .. #EXPECT: Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index dd466456f..bc221d5dd 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -208,8 +208,7 @@ def test_rst_files( # ║ Enable this if you need to see errors for debugging ║ # ║ purposes ║ # ╙ ╜ - - # print("\n".join(strip_ansi_codes(w) for w in raw_warnings)) + # print("\n".join(strip_ansi_codes(w) for w in warnings)) # Check if the expected warnings are present for warning_info in rst_data.warning_infos: From 2d884645c1c358de9c033ebccaebaec6b5aa6abe Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 29 May 2026 10:54:12 +0200 Subject: [PATCH 14/17] Fix: Fix test wording --- .../score_metamodel/tests/rst/options/test_need_extends.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst index bd5e8987f..b3c41c3ce 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -56,7 +56,7 @@ :satisfies: stkh_req__test__need_extends_abc -#EXPECT: Replace or Delete action is not allowed via needextends. +#EXPECT: Delete action is not allowed via needextends. .. needextend:: id == 'stkh_req__test__need_extends_1' :-safety: From e1108926bbf28bda59fb882c4d5224172e1134f1 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 29 May 2026 11:24:07 +0200 Subject: [PATCH 15/17] Fix: Error message in need_extends --- .../checks/check_needs_extends.py | 16 ++++++++++++---- .../tests/rst/options/test_need_extends.rst | 8 ++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index edd686ba9..5b98c54a1 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -81,7 +81,8 @@ def score_extend_needs_data_func( # noqa: C901 if found_need["is_external"]: log_warning( logger, - "Error. It is not allowed to modify external needs via needextend", + f"Error when extending need: {found_need['id']} " + + "It is not allowed to modify external needs via needextend", "needextend", location, ) @@ -101,6 +102,7 @@ def score_extend_needs_data_func( # noqa: C901 ): # Replacing / Deleting links is not allowed error_msg = ( + f"Error when extending need: {found_need['id']} " "Replace or Delete action is not allowed via needextends." ) # logger.warning_for_need(current_needextend["id"], error_msg) @@ -108,12 +110,18 @@ def score_extend_needs_data_func( # noqa: C901 for option_name, etype, field_value in current_needextend["modifications"]: if etype == ExtendType.DELETE: - error_msg = "Delete action is not allowed via needextends." + error_msg = ( + f"Error when extending need: {found_need['id']} " + "Delete action is not allowed via needextends." + ) log_warning(logger, error_msg, "needextend", location=location) match (etype, field_value): case (ExtendType.APPEND, FieldLiteralValue()): if isinstance(field_value.value, str): - error_msg = "Append action is not allowed via needextends on 'string type options'." + error_msg = ( + f"Error when extending need: {found_need['id']} " + "Append action is not allowed via needextends on 'string type options'." + ) log_warning( logger, error_msg, "needextend", location=location ) @@ -123,7 +131,7 @@ def score_extend_needs_data_func( # noqa: C901 None | FieldLiteralValue() | FieldFunctionArray(), ): if need[option_name]: - error_msg = f"Error when extending need: {need['id']}. Replacing of options that are already set is not allowed via needextends." + error_msg = f"Error when extending need: {need['id']} Replacing of options that are already set is not allowed via needextends." log_warning( logger, error_msg, "needextend", location=location diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst index b3c41c3ce..5fc874419 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -29,7 +29,7 @@ .. Replacing of options that are already set is not allowed. -#EXPECT: Replacing of options that are already set is not allowed via needextends. +#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Replacing of options that are already set is not allowed via needextends. .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' :status: valid @@ -50,19 +50,19 @@ :safety: NO -# EXPECT: Replace or Delete action is not allowed via needextends. +# EXPECT: Error when extending need: stkh_req__test__need_extends_abc Replace or Delete action is not allowed via needextends. .. needextend:: feat_req__test__linkage_override :satisfies: stkh_req__test__need_extends_abc -#EXPECT: Delete action is not allowed via needextends. +#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Delete action is not allowed via needextends. .. needextend:: id == 'stkh_req__test__need_extends_1' :-safety: -#EXPECT: Append action is not allowed via needextends on 'string type options' +#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Append action is not allowed via needextends on 'string type options' .. needextend:: id == 'stkh_req__test__need_extends_1' :+safety: YES From b9b8f083328e7c12da5d3ff9dd822cbe2fabf8bd Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Fri, 29 May 2026 11:56:24 +0200 Subject: [PATCH 16/17] Fix: Error msg clarity & tests fixes --- .../score_metamodel/checks/check_needs_extends.py | 10 +++++----- .../tests/rst/options/test_need_extends.rst | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py index 5b98c54a1..f4c79bc65 100644 --- a/src/extensions/score_metamodel/checks/check_needs_extends.py +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -81,7 +81,7 @@ def score_extend_needs_data_func( # noqa: C901 if found_need["is_external"]: log_warning( logger, - f"Error when extending need: {found_need['id']} " + f"Error when extending need: {found_need['id']}. " + "It is not allowed to modify external needs via needextend", "needextend", location, @@ -102,7 +102,7 @@ def score_extend_needs_data_func( # noqa: C901 ): # Replacing / Deleting links is not allowed error_msg = ( - f"Error when extending need: {found_need['id']} " + f"Error when extending need: {need['id']}. " "Replace or Delete action is not allowed via needextends." ) # logger.warning_for_need(current_needextend["id"], error_msg) @@ -111,7 +111,7 @@ def score_extend_needs_data_func( # noqa: C901 for option_name, etype, field_value in current_needextend["modifications"]: if etype == ExtendType.DELETE: error_msg = ( - f"Error when extending need: {found_need['id']} " + f"Error when extending need: {need['id']}. " "Delete action is not allowed via needextends." ) log_warning(logger, error_msg, "needextend", location=location) @@ -119,7 +119,7 @@ def score_extend_needs_data_func( # noqa: C901 case (ExtendType.APPEND, FieldLiteralValue()): if isinstance(field_value.value, str): error_msg = ( - f"Error when extending need: {found_need['id']} " + f"Error when extending need: {need['id']}. " "Append action is not allowed via needextends on 'string type options'." ) log_warning( @@ -131,7 +131,7 @@ def score_extend_needs_data_func( # noqa: C901 None | FieldLiteralValue() | FieldFunctionArray(), ): if need[option_name]: - error_msg = f"Error when extending need: {need['id']} Replacing of options that are already set is not allowed via needextends." + error_msg = f"Error when extending need: {need['id']}. Replacing of options that are already set is not allowed via needextends." log_warning( logger, error_msg, "needextend", location=location diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst index 5fc874419..10fb3f0c8 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -29,7 +29,7 @@ .. Replacing of options that are already set is not allowed. -#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Replacing of options that are already set is not allowed via needextends. +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Replacing of options that are already set is not allowed via needextends. .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' :status: valid @@ -50,19 +50,19 @@ :safety: NO -# EXPECT: Error when extending need: stkh_req__test__need_extends_abc Replace or Delete action is not allowed via needextends. +#EXPECT: Error when extending need: feat_req__test__linkage_override. Replace or Delete action is not allowed via needextends. .. needextend:: feat_req__test__linkage_override :satisfies: stkh_req__test__need_extends_abc -#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Delete action is not allowed via needextends. +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Delete action is not allowed via needextends. .. needextend:: id == 'stkh_req__test__need_extends_1' :-safety: -#EXPECT: Error when extending need: stkh_req__test__need_extends_1 Append action is not allowed via needextends on 'string type options' +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Append action is not allowed via needextends on 'string type options' .. needextend:: id == 'stkh_req__test__need_extends_1' :+safety: YES From 7ae33374b05054b9eff6874046356f034600caee Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 29 May 2026 12:17:55 +0200 Subject: [PATCH 17/17] Fix: typos in write_docs.rst --- docs/how-to/write_docs.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst index 7cbd274a9..68f43676d 100644 --- a/docs/how-to/write_docs.rst +++ b/docs/how-to/write_docs.rst @@ -34,10 +34,10 @@ Advanced Needextend ~~~~~~~~~~ -Needextend allows you to extend needs that are defined in the documentaiton. +Needextend allows you to extend needs that are defined in the documentation. The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases. -Allowed uscases: +Allowed usecases: * Setting an attribute or Link **IF** it is not already set in the need that is getting modified * Appending to a list of links