Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/how-to/write_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
Write Documentation
===================

Outside Documentation
^^^^^^^^^^^^^^^^^^^^^

`Sphinx <https://www.sphinx-doc.org/en/master/>`_:
the documentation generator we use.

Expand All @@ -23,3 +26,47 @@ the plain-text markup language used for most source files in this project.

`Sphinx-Needs <https://sphinx-needs.readthedocs.io/en/latest/>`_:
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 <https://sphinx-needs.readthedocs.io/en/latest/directives/needextend.html>`_

.. 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.
20 changes: 17 additions & 3 deletions src/extensions/score_metamodel/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,30 @@ 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(
[
"tests/**/*.rst",
"tests/**/*.yaml",
],
) + ["tests/rst/conf.py"],
deps = [":score_metamodel"],
pytest_config = "//:pyproject.toml",
deps = [":score_metamodel"],
)
142 changes: 142 additions & 0 deletions src/extensions/score_metamodel/checks/check_needs_extends.py
Original file line number Diff line number Diff line change
@@ -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,
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review (Claude):

Suggestion: external-need branch warns but continues processing

After logging the warning for an external need, the loop falls through and still executes the modification checks below (lines 89–130) for that same need. The original extend_needs_data will then apply the actual change. This means the user gets a warning and the modification still happens.

If modifying external needs is genuinely disallowed, this should continue after the warning (or the original function should skip it too). If it's just advisory for now, the message "It is not allowed" is misleading β€” consider "Warning: modifying external needs is discouraged" until it's enforced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ye that's not an issue, you get the warning and the build fails.
Though we could do early exits to speed it up, that mightn ot be a bad idea.

# Work in the stored needs, not on the search result
need = all_needs[found_need["id"]]

location = (
current_needextend["docname"],
current_needextend["lineno"],
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review (Claude):

Suggestion: location is re-assigned inside the per-need loop, shadowing the outer variable

location is first set on line 44 for the current needextend directive, then reassigned on lines 91–94 inside the for found_need in found_needs loop. The second assignment is identical to the first β€” it just copies the same values. This creates a subtle trap: if a future change needs the needextend's location before the inner loop, the pre-loop assignment on line 44 looks authoritative but gets clobbered. Remove the redundant inner reassignment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is taken stragith from the function. I'm not changing that, it is how the original function works.


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
Comment thread
MaximilianSoerenPollak marked this conversation as resolved.
4 changes: 4 additions & 0 deletions src/extensions/score_metamodel/tests/rst/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review (Claude):

Important: Three validation branches have no test coverage

The checker has five distinct warning paths. Only two are exercised here:

Branch Covered?
list_modifications: REPLACE/DELETE on links ❌
modifications: DELETE ❌
modifications: APPEND on string option ❌
modifications: REPLACE when option already set βœ… (line 23)
modifications: REPLACE when option not set βœ… (line 32)

The three uncovered branches represent ~60% of the warning logic. Adding RST fixtures for at least list_modifications replace-on-links and DELETE would significantly increase confidence that the logic works as intended.

29 changes: 12 additions & 17 deletions src/extensions/score_metamodel/tests/test_rules_file_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Loading