diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst
index c1daaf191..68f43676d 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,47 @@ 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 documentation.
+The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases.
+
+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
+
+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.
diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD
index 82fe3eaed..3ddd80e15 100644
--- a/src/extensions/score_metamodel/BUILD
+++ b/src/extensions/score_metamodel/BUILD
@@ -60,9 +60,23 @@ 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"],
+ ),
+ data = ["tests/model/simple_model.yaml"],
+ pytest_config = "//:pyproject.toml",
+ deps = [":score_metamodel"],
+)
+
+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(
[
@@ -70,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
new file mode 100644
index 000000000..f4c79bc65
--- /dev/null
+++ b/src/extensions/score_metamodel/checks/check_needs_extends.py
@@ -0,0 +1,142 @@
+# *******************************************************************************
+# 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
+
+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.needs_schema import (
+ FieldFunctionArray,
+ FieldLiteralValue,
+ LinksFunctionArray,
+ LinksLiteralValue,
+)
+
+logger = get_logger(__name__)
+
+
+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."""
+ # regardless of parallel build worker completion order.
+ sorted_extends = sorted(extends.values(), key=lambda x: (x["docname"], x["lineno"]))
+
+ current_needextend: NeedsExtendType
+ for current_needextend in sorted_extends:
+ need_filter = current_needextend["filter"]
+ location = (current_needextend["docname"], current_needextend["lineno"])
+
+ # ╓ ╖
+ # ║ 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:
+ 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) from KeyError
+ 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:
+ if found_need["is_external"]:
+ log_warning(
+ logger,
+ f"Error when extending need: {found_need['id']}. "
+ + "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"]]
+
+ location = (
+ current_needextend["docname"],
+ current_needextend["lineno"],
+ )
+
+ for _, etype, link_value in current_needextend["list_modifications"]:
+ match (etype, link_value):
+ case (
+ ExtendType.REPLACE | ExtendType.DELETE,
+ LinksLiteralValue() | LinksFunctionArray(),
+ ):
+ # Replacing / Deleting links is not allowed
+ error_msg = (
+ 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)
+ log_warning(logger, error_msg, "needextend", location=location)
+
+ for option_name, etype, field_value in current_needextend["modifications"]:
+ if etype == ExtendType.DELETE:
+ error_msg = (
+ f"Error when extending need: {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 = (
+ f"Error when extending need: {need['id']}. "
+ "Append action is not allowed via needextends on 'string type options'."
+ )
+ log_warning(
+ logger, error_msg, "needextend", location=location
+ )
+
+ 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.extend_needs_data = score_extend_needs_data_func
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
new file mode 100644
index 000000000..10fb3f0c8
--- /dev/null
+++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst
@@ -0,0 +1,75 @@
+..
+ # *******************************************************************************
+ # 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
+
+
+.. 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.
+
+#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
+
+
+.. 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-NOT: Replacing of options
+
+.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1'
+ :safety: NO
+
+
+#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.
+
+.. 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'
+
+.. 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
+
+.. .. 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 73a65962c..bc221d5dd 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
@@ -158,11 +152,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 +197,18 @@ 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: