Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions docs/how-to/write_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ For further documentation on needextends please `look here <https://sphinx-needs

.. 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.
Needextends may only modify needs in the current document. Add `c.this_doc()`
to the filter string of the needextend as shown in the examples above.


Requirement ID Feature Part
Expand Down
272 changes: 168 additions & 104 deletions src/extensions/score_metamodel/checks/check_needs_extends.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@

import sphinx_needs.directives.need
from sphinx_needs.config import NeedsSphinxConfig
from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable
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.need_item import NeedItem
from sphinx_needs.needs_schema import (
FieldFunctionArray,
FieldLiteralValue,
Expand All @@ -28,114 +32,174 @@

logger = get_logger(__name__)

NeedextendLocation = tuple[str, int]


def _location(needextend: NeedsExtendType) -> NeedextendLocation:
"""Return the source location used for every diagnostic of one directive."""
return needextend["docname"], needextend["lineno"]


def _warn(message: str, location: NeedextendLocation) -> None:
"""Report a needextend violation at the directive, not at its target need."""
log_warning(logger, message, "needextend", location=location)


def _verify_needs_are_in_document(
needs: list[NeedItem], location: NeedextendLocation
) -> None:
"""Report selected needs outside the directive's source document.

Both ID and expression targets resolve to need records. Validating that shared
representation keeps the document-boundary policy identical for both syntaxes.
"""
remote_ids = {need["id"] for need in needs if need["docname"] != location[0]}
if remote_ids:
_warn(
"Needextends may only modify needs in the current document. "
f"Matching needs: {', '.join(sorted(remote_ids))}.",
location,
)


def score_extend_needs_data_func( # noqa: C901
def _fetch_needs(
all_needs: NeedsMutable,
needextend: NeedsExtendType,
needs_config: NeedsSphinxConfig,
) -> list[NeedItem]:
"""Resolve either supported target syntax to the needs it selects."""
location = _location(needextend)

if needextend["filter_is_id"]:
# ``.. needextend:: NEED_ID`` has no expression to constrain, so inspect
# its resolved target in the shared document-boundary check below.
need_id = needextend["filter"]
try:
return [all_needs[need_id]]
except KeyError:
_warn(
f"Provided id {need_id!r} for needextend does not exist.",
location,
)
return []
Comment on lines +76 to +84

else:
need_filter = needextend["filter"]

if "c.this_doc()" not in need_filter:
_warn(
"needextend in S-CORE must always be used per document only. "
"Please add 'c.this_doc()' to the needextend to limit its effects to the correct document. "
"See https://eclipse-score.github.io/docs-as-code/main/how-to/write_docs.html#needextend for more information.",
location,
)

try:
return filter_needs_mutable(
all_needs,
needs_config,
need_filter,
location=location,
origin_docname=location[0],
)
except Exception as e:
_warn(f"Invalid filter {need_filter!r}: {e}", location)
return []
Comment on lines +97 to +107


def _validate_list_modifications(
need: NeedItem,
needextend: NeedsExtendType,
location: NeedextendLocation,
) -> None:
"""Reject destructive changes to link lists, which would erase traceability."""
for _, action, value in needextend["list_modifications"]:
replaces_or_deletes_links = action in {
ExtendType.REPLACE,
ExtendType.DELETE,
} and isinstance(value, LinksLiteralValue | LinksFunctionArray)
if replaces_or_deletes_links:
_warn(
f"Error when extending need: {need['id']}. "
"Replace or Delete action is not allowed via needextends.",
location,
)


def _validate_field_modifications(
need: NeedItem,
needextend: NeedsExtendType,
location: NeedextendLocation,
) -> None:
"""Reject field changes that discard data or append to scalar fields."""
for option_name, action, value in needextend["modifications"]:
is_scalar_append = (
action == ExtendType.APPEND
and isinstance(value, FieldLiteralValue)
and isinstance(value.value, str)
)
is_supported_replacement = action == ExtendType.REPLACE and (
value is None or isinstance(value, FieldLiteralValue | FieldFunctionArray)
)

if action == ExtendType.DELETE:
_warn(
f"Error when extending need: {need['id']}. "
"Delete action is not allowed via needextends.",
location,
)
elif is_scalar_append:
_warn(
f"Error when extending need: {need['id']}. "
"Append action is not allowed via needextends on 'string type options'.",
location,
)
elif is_supported_replacement and need[option_name]:
_warn(
f"Error when extending need: {need['id']}. "
"Replacing of options that are already set is not allowed via needextends.",
location,
)


def _ensure_non_destructive_changes(
need: NeedItem,
needextend: NeedsExtendType,
location: NeedextendLocation,
) -> None:
"""Apply SCORE's non-destructive extension policy to one selected need."""
if need["is_external"]:
_warn(
f"Error when extending need: {need['id']}. "
"It is not allowed to modify external needs via needextend",
location,
)
_validate_list_modifications(need, needextend, location)
_validate_field_modifications(need, needextend, location)


def score_extend_needs_data_func(
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"],
)
"""Validate SCORE's needextend policy, then let Sphinx-Needs apply it.

This wrapper intentionally only reports violations. The unmodified directives
are still passed to Sphinx-Needs so its normal processing and diagnostics remain
authoritative.
"""
# Sphinx-Needs applies extensions in source order as well. Matching that order
# keeps warning output stable and mirrors the later application order.
ordered_extends = sorted(extends.values(), key=_location)

for needextend in ordered_extends:
needs = _fetch_needs(all_needs, needextend, needs_config)
_verify_needs_are_in_document(needs, _location(needextend))

for n in needs:
_ensure_non_destructive_changes(n, needextend, _location(needextend))

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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@
:expect: Error when extending need: stkh_req__test__need_extends_3. Append action is not allowed via needextends on 'string type options'


.. This will be activated once we have activated the c.this_doc() check aswell
.. #EXPECT[+2]: 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
.. A needextend must explicitly be limited to needs in its own document.

.. .. needextend: id == 'stkh_req__test__need_extends_1'
.. :security: QM
.. needextend:: id == 'stkh_req__test__need_extends_1'
:security: QM
:expect: needextend in S-CORE must always be used per document only. Please add 'c.this_doc()' to the needextend to limit its effects to the correct document.
Loading
Loading