Skip to content
Merged
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: 4 additions & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Claude Code creates .claude directory, e.g. for worktrees.
# As it contains multiple copies of the entire repository, this totally tripps bazel,
# which then tries to build all the files in there.
.claude
70 changes: 70 additions & 0 deletions docs/internals/extensions/rst_filebased_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,73 @@ a new line between the EXPECT/-NOT and the need that it refers to

This will error and let you know that an offset of '1' is not allowed and you
need to add a new line beneath the Warning Statement

## Graph check tests

Graph checks (defined in `metamodel.yaml` under `graph_checks:`) are all executed
by a single Python function `check_metamodel_graph`. Use that function name in `#CHECK:`:

#CHECK: check_metamodel_graph

Graph check test files live in `tests/rst/graph/`.

### Warning message format

`check_metamodel_graph` emits warnings in this shape:

{need_id}: Parent need `{parent_id}` does not fulfill condition `{condition}`. Explanation: {explanation}

You only need to match a unique substring, not the full message. For example:

#EXPECT[+2]: wp__bad: Parent need `std_req__aspice_40__bp`

### Setup needs

Prerequisite needs (link targets, shared fixtures) belong at the top of the file
with no `EXPECT`/`EXPECT-NOT` annotation. The framework only checks annotated lines,
so unannotated needs are invisible to the assertion logic.

### The "exempt" case

A need that does not meet the `condition:` in the YAML check definition is not
selected by the check at all — no warning is emitted. Test this with `EXPECT-NOT`:

#EXPECT-NOT[+2]: <something from the explanation>

.. workproduct:: No link at all
:id: wp__no_link

### Full example (graph check)

#CHECK: check_metamodel_graph

.. std_req:: ASPICE 40 IIC requirement
:id: std_req__aspice_40__iic_1
:status: valid

.. std_req:: ASPICE 40 non-IIC requirement
:id: std_req__aspice_40__bp_1
:status: valid

.. Positive: links to allowed target — no warning.

#EXPECT-NOT[+2]: ASPICE 40 IIC

.. workproduct:: Valid workproduct
:id: wp__valid
:complies: std_req__aspice_40__iic_1

.. Exempt: no complies link — condition not met, check skipped.

#EXPECT-NOT[+2]: ASPICE 40 IIC

.. workproduct:: Workproduct without link
:id: wp__no_link

.. Negative: links to disallowed target — warning expected.

#EXPECT[+2]: wp__bad: Parent need `std_req__aspice_40__bp_1`

.. workproduct:: Invalid workproduct
:id: wp__bad
:complies: std_req__aspice_40__bp_1
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ junit_family = "xunit1"

filterwarnings = [
"ignore::pytest.PytestExperimentalApiWarning",

# We'll deal with these when it is time.
# No need to spam all the logs all the time.
"ignore:.*BuildEnvironment.app.*:sphinx.deprecation.RemovedInSphinx11Warning",
"ignore:.*frontend.OptionParser.*:DeprecationWarning",
"ignore:.*frontend.Option class.*:DeprecationWarning",
]

pythonpath = [
"src/extensions/",
]
69 changes: 35 additions & 34 deletions src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,51 +165,52 @@ def _resolve_linkable_types(
link_name: str,
link_value: str,
current_need_type: ScoreNeedType,
needs_types: list[ScoreNeedType],
) -> list[ScoreNeedType | str]:
needs_types_dict = {nt["directive"]: nt for nt in needs_types}
needs_types: dict[str, ScoreNeedType],
) -> list[ScoreNeedType]:
# Anything is allowed if the value is "ANY". This allows to bypass the link type validation for specific links.
if link_value == "ANY":
return list(needs_types.values())

link_values = [v.strip() for v in link_value.split(",")]
linkable_types: list[ScoreNeedType | str] = []
linkable_types: list[ScoreNeedType] = []
for v in link_values:
if v.startswith("^"):
linkable_types.append(v) # keep regex as-is
target_need_type = needs_types.get(v)
if target_need_type is None:
logger.error(
f"In metamodel.yaml: {current_need_type['directive']}, "
f"link '{link_name}' references unknown type '{v}'."
)
else:
target_need_type = needs_types_dict.get(v)
if target_need_type is None:
logger.error(
f"In metamodel.yaml: {current_need_type['directive']}, "
f"link '{link_name}' references unknown type '{v}'."
)
else:
linkable_types.append(target_need_type)
linkable_types.append(target_need_type)
return linkable_types


def postprocess_need_links(needs_types_list: list[ScoreNeedType]):
"""Convert link option strings into lists of target need types.

If a link value starts with '^' it is treated as a regex and left
unchanged. Otherwise it is a comma-separated list of type names which
are resolved to the corresponding ScoreNeedTypes.
Parses comma-separated list of type names which are resolved to the corresponding
ScoreNeedTypes.
"""
for need_type in needs_types_list:
try:
link_dicts = (
need_type["mandatory_links"],
need_type["optional_links"],
# "mandatory_links_str" is used to keep only metamodel sourced types. That key is so
# specific, that noone but our metamodel should be using it.
all_need_types = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this all_need_types? It reads like a filtered version already.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it's "all our types" aka "all types from metamodel"

nt["directive"]: nt for nt in needs_types_list if "mandatory_links_str" in nt
}

for need_type in all_need_types.values():
need_type["mandatory_links"] = {
link_name: _resolve_linkable_types(
link_name, link_value, need_type, all_need_types
)
for link_name, link_value in need_type["mandatory_links_str"].items()
}

need_type["optional_links"] = {
link_name: _resolve_linkable_types(
link_name, link_value, need_type, all_need_types
)
except KeyError:
# TODO: remove the Sphinx-Needs defaults from our metamodel
# Example: {'directive': 'issue', 'title': 'Issue', 'prefix': 'IS_'}
continue

for link_dict in link_dicts:
for link_name, link_value in link_dict.items():
assert isinstance(link_value, str) # so far all of them are strings

link_dict[link_name] = _resolve_linkable_types( # pyright: ignore[reportArgumentType]
link_name, link_value, need_type, needs_types_list
)
for link_name, link_value in need_type["optional_links_str"].items()
}


def _clear_needs_defaults(app: Sphinx):
Expand Down
32 changes: 19 additions & 13 deletions src/extensions/score_metamodel/checks/check_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
default_options,
local_check,
)
from score_metamodel.metamodel_types import AllowedLinksType
from sphinx.application import Sphinx
from sphinx_needs.need_item import NeedItem

Expand Down Expand Up @@ -134,19 +133,23 @@
# )


def _to_link_pattern(value: "str | ScoreNeedType") -> str:
"""Convert a link constraint to a regex pattern.
def _to_link_pattern(value: ScoreNeedType) -> str:
"""
Convert a link constraint to a regex pattern.

- A plain type name like ``"stkh_req"`` becomes ``^stkh_req__``
(matching IDs that start with the type name followed by ``__``).
- A string already starting with ``^`` is treated as an explicit regex
and returned unchanged.
- A ScoreNeedType dict uses its ``mandatory_options.id`` pattern.
Note: the pattern is already stored in "id" attribute, this is just a helper
function to retrieve it safely.
"""
if isinstance(value, str):
if value.startswith("^"):
return value
return f"^{value}__"
assert isinstance(value, dict), f"Expected dict for ScoreNeedType, got {value}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you not do is instance to make sure it's actually a ScoreNeedType?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, isinstance does not work with TypedDict. As the type info is not a real type AFAIK.

assert "mandatory_options" in value, (
f"ScoreNeedType dict must have 'mandatory_options', got {value}"
)
assert isinstance(value["mandatory_options"], dict), (
f"'mandatory_options' must be a dict, got {value['mandatory_options']}"
)
assert "id" in value["mandatory_options"], (
f"'mandatory_options' must contain 'id', got {value['mandatory_options']}"
)
return value["mandatory_options"]["id"]


Expand All @@ -160,10 +163,12 @@
"""

def _validate(
attributes_to_allowed_values: AllowedLinksType,
attributes_to_allowed_values: dict[str, list[ScoreNeedType]] | None,
mandatory: bool,
treat_as_info: bool = False,
):
assert attributes_to_allowed_values is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Kinda an antipattern no?
If the typehint is allowed to be None, but here you assert that it should never happen?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yeah... its not pretty. This avoid checking the argument on all call sites, which would be even uglier.


for attribute, allowed_values in attributes_to_allowed_values.items():
values = _get_normalized(need, attribute)
if mandatory and not values:
Expand Down Expand Up @@ -238,7 +243,8 @@
"mandatory_links",
"optional_links",
):
assert need_options[o] is not None
allowed_options.update(need_options[o].keys())

Check failure on line 247 in src/extensions/score_metamodel/checks/check_options.py

View workflow job for this annotation

GitHub Actions / Run pre-commit checks

"keys" is not a known attribute of "None" (reportOptionalMemberAccess)

extra_options = [
option
Expand Down
29 changes: 22 additions & 7 deletions src/extensions/score_metamodel/checks/graph_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@
graph_check,
)
from sphinx.application import Sphinx
from sphinx_needs import logging
from sphinx_needs.config import NeedType
from sphinx_needs.data import NeedsView
from sphinx_needs.need_item import NeedItem

# This is the normal logger for this module, not for warnings on specific needs.
# Use CheckLogger for that, which allows us to log the need id and location together with the warning message.
logger = logging.get_logger(__name__)


def eval_need_check(need: NeedItem, check: str, log: CheckLogger) -> bool:
"""
Expand All @@ -40,6 +45,7 @@ def eval_need_check(need: NeedItem, check: str, log: CheckLogger) -> bool:
"<": operator.lt,
">=": operator.ge,
"<=": operator.le,
"contains": lambda a, b: b in a if isinstance(a, str) else False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This then allows for b to be a non string type. Is that wanted behaviour?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

no, but I did not figure out how to say "else fail horribly" - without changing how the function is designed.

}

parts = check.split(" ")
Expand Down Expand Up @@ -69,11 +75,9 @@ def eval_need_condition(
Recursively call the eval_need_function for each check and combine the
results with the binary operation which was specified in the yaml file.
"""

oper: dict[str, Any] = {
"and": operator.and_,
"or": operator.or_,
"not": lambda x: not x,
"xor": operator.xor,
}

Expand All @@ -91,13 +95,18 @@ def eval_need_condition(
if cond == "not":
if not isinstance(vals, list) or len(vals) != 1:
raise ValueError("Operator 'not' requires exactly one operand.")
return oper["not"](eval_need_condition(need, vals[0], log))

if cond in ["and", "or", "xor"]:
return not eval_need_condition(need, vals[0], log)

if cond in oper:
if not isinstance(vals, list) or len(vals) <= 1:
raise ValueError(f"Operator '{cond}' requires at least two operands.")

return reduce(
lambda a, b: oper[cond](a, b),
(eval_need_condition(need, val, log) for val in vals),
)

raise ValueError(f"Unsupported condition operator: {cond}")


Expand Down Expand Up @@ -175,9 +184,15 @@ def check_metamodel_graph(
"Explanations are mandatory for graph checks."
)
# Get all needs matching the selection criteria
selected_needs = filter_needs_by_criteria(
app.config.needs_types, needs_local, needs_selection_criteria, log
)
try:
selected_needs = filter_needs_by_criteria(
app.config.needs_types, needs_local, needs_selection_criteria, log
)
except ValueError as e:
# Turn a 3 page callstack into a readable error message for the user, since
# this is a configuration error in the yaml file.
logger.error(f"Error in graph check `{check_name}`: {e}")
continue

for need in selected_needs:
for parent_relation in list(check_to_perform.keys()):
Expand Down
Loading
Loading