From 57aa954a7fdef44605c4703623dde7105c4d41a7 Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Thu, 18 Jun 2026 12:47:52 -0300 Subject: [PATCH 01/10] feat(traceability): allow unresolved relation requirements --- docs/sdoc_project_statistics.py | 21 +++- strictdoc/commands/export.py | 10 ++ strictdoc/commands/export_config.py | 6 + strictdoc/commands/server.py | 10 ++ strictdoc/commands/server_config.py | 6 + strictdoc/core/project_config.py | 37 ++++++ strictdoc/core/query_engine/grammar.py | 12 ++ strictdoc/core/query_engine/query_object.py | 25 ++++ strictdoc/core/query_engine/query_reader.py | 4 + strictdoc/core/traceability_index.py | 106 ++++++++++++++++ strictdoc/core/traceability_index_builder.py | 8 ++ strictdoc/features/export/export_action.py | 42 +++++++ .../features/project_statistics/generator.py | 12 ++ .../models/project_tree_stats.py | 1 + .../view_project_statistics/test_case.py | 1 + .../input.sdoc | 15 +++ .../strictdoc.toml | 8 ++ .../test.itest | 15 +++ .../core/query_engine/test_query_reader.py | 26 ++++ .../strictdoc/core/test_traceability_index.py | 114 ++++++++++++++++++ 20 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/input.sdoc create mode 100644 tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/strictdoc.toml create mode 100644 tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/test.itest diff --git a/docs/sdoc_project_statistics.py b/docs/sdoc_project_statistics.py index 4dd36c206..720ae9aef 100644 --- a/docs/sdoc_project_statistics.py +++ b/docs/sdoc_project_statistics.py @@ -11,13 +11,13 @@ ) from strictdoc.core.document_iterator import SDocDocumentIterator from strictdoc.core.project_config import ProjectConfig -from strictdoc.core.statistics.metric import Metric, MetricSection from strictdoc.core.traceability_index import TraceabilityIndex -from strictdoc.export.html.generators.view_objects.project_statistics_view_object import ( - ProjectStatisticsViewObject, -) from strictdoc.export.html.html_templates import HTMLTemplates from strictdoc.export.html.renderers.link_renderer import LinkRenderer +from strictdoc.features.project_statistics.metric import Metric, MetricSection +from strictdoc.features.project_statistics.view_object import ( + ProjectStatisticsViewObject, +) from strictdoc.helpers.cast import assert_cast from strictdoc.helpers.git_client import GitClient @@ -50,6 +50,7 @@ class DocumentTreeStats: requirements_no_uid: int = 0 requirements_no_links: int = 0 requirements_root_no_links: int = 0 + requirements_missing_relations: int = 0 requirements_no_rationale: int = 0 # STATUS. @@ -125,6 +126,11 @@ def export( ): document_tree_stats.requirements_no_links += 1 + if traceability_index.has_missing_relations_for_requirement( + requirement + ): + document_tree_stats.requirements_missing_relations += 1 + # RATIONALE. if ( requirement.ordered_fields_lookup.get("RATIONALE") @@ -254,6 +260,13 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) + section.metrics.append( + Metric( + name="Requirements with missing relations", + value=str(document_tree_stats.requirements_missing_relations), + link="search?q=(node.is_requirement() and node.has_missing_relations)", + ) + ) section.metrics.append( Metric( name="Requirements with no RATIONALE", diff --git a/strictdoc/commands/export.py b/strictdoc/commands/export.py index 72b72c1ef..95816d054 100644 --- a/strictdoc/commands/export.py +++ b/strictdoc/commands/export.py @@ -176,6 +176,16 @@ def add_arguments(cls, parser: argparse.ArgumentParser) -> None: "If not given, chromedriver is downloaded and saved to " "strictdoc cache.", ) + command_parser_export.add_argument( + "--allow-missing-relation-requirements", + dest="allow_missing_relation_requirements", + action="store_true", + default=False, + help=( + "Allow exporting even when some requirement relations " + "(parent/child) cannot be resolved." + ), + ) command_parser_export.add_argument( "--config", type=str, diff --git a/strictdoc/commands/export_config.py b/strictdoc/commands/export_config.py index 612ae331f..2829fb92f 100644 --- a/strictdoc/commands/export_config.py +++ b/strictdoc/commands/export_config.py @@ -30,6 +30,8 @@ def __init__( generate_diff_git: Optional[str], generate_diff_dirs: Optional[Tuple[str, str]], chromedriver: Optional[str], + allow_missing_relation_requirements: bool = False, + allow_missing_parent_requirements: bool = False, ): assert isinstance(input_paths, list), f"{input_paths}" self.debug: bool = debug @@ -52,6 +54,10 @@ def __init__( self.generate_diff_git: Optional[str] = generate_diff_git self.generate_diff_dirs: Optional[Tuple[str, str]] = generate_diff_dirs self.chromedriver: Optional[str] = chromedriver + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + or allow_missing_parent_requirements + ) def get_path_to_config(self) -> str: # FIXME: The control flow can be improved. diff --git a/strictdoc/commands/server.py b/strictdoc/commands/server.py index c0acfdc9e..a4ab7607e 100644 --- a/strictdoc/commands/server.py +++ b/strictdoc/commands/server.py @@ -36,6 +36,16 @@ def add_arguments(cls, parser: argparse.ArgumentParser) -> None: action="store_false", help=argparse.SUPPRESS, ) + command_parser_server.add_argument( + "--allow-missing-relation-requirements", + dest="allow_missing_relation_requirements", + action="store_true", + default=False, + help=( + "Allow starting the server even when some requirement " + "relations (parent/child) cannot be resolved." + ), + ) command_parser_server.add_argument( "--config", type=str, diff --git a/strictdoc/commands/server_config.py b/strictdoc/commands/server_config.py index 45dcebfc9..0f256c503 100644 --- a/strictdoc/commands/server_config.py +++ b/strictdoc/commands/server_config.py @@ -19,6 +19,8 @@ def __init__( reload: bool, host: Optional[str], port: Optional[int], + allow_missing_relation_requirements: bool = False, + allow_missing_parent_requirements: bool = False, ): self.debug: bool = debug self.command: str = command @@ -28,6 +30,10 @@ def __init__( self.reload: bool = reload self.host: Optional[str] = host self.port: Optional[int] = port + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + or allow_missing_parent_requirements + ) def get_full_input_path(self) -> str: return os.path.abspath(self._input_path) diff --git a/strictdoc/core/project_config.py b/strictdoc/core/project_config.py index 680bed317..13e5758b4 100644 --- a/strictdoc/core/project_config.py +++ b/strictdoc/core/project_config.py @@ -153,6 +153,8 @@ def __init__( ] = ProjectConfigDefault.DEFAULT_SECTION_BEHAVIOR, statistics_generator: Optional[str] = None, document_line_width: Optional[int] = None, + allow_missing_relation_requirements: bool = False, + allow_missing_parent_requirements: Optional[bool] = None, # Logo path can be set in the project config to customize the launcher's appearance for a specific project. launcher_logo_path: Optional[str] = None, user_plugin: Optional[StrictDocPlugin] = None, @@ -394,6 +396,23 @@ def __init__( ) self.document_line_width: Optional[int] = document_line_width + if allow_missing_parent_requirements is not None: + allow_missing_relation_requirements = ( + allow_missing_relation_requirements + or allow_missing_parent_requirements + ) + + assert isinstance( + allow_missing_relation_requirements, bool + ), allow_missing_relation_requirements + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + ) + # Backward-compatible alias. + self.allow_missing_parent_requirements: bool = ( + self.allow_missing_relation_requirements + ) + self.user_plugin: Optional[StrictDocPlugin] = user_plugin # Optional launcher logo path (absolute or workspace-relative). @@ -450,6 +469,10 @@ def integrate_server_config( self.generate_bundle_document = False self.export_included_documents = True + if server_config.allow_missing_relation_requirements: + self.allow_missing_relation_requirements = True + self.allow_missing_parent_requirements = True + def integrate_export_config( self, export_config: ExportCommandConfig ) -> None: @@ -518,6 +541,10 @@ def integrate_export_config( if not self.reqif_enable_mid: self.reqif_enable_mid = export_config.reqif_enable_mid + if export_config.allow_missing_relation_requirements: + self.allow_missing_relation_requirements = True + self.allow_missing_parent_requirements = True + def validate_and_finalize(self) -> None: project_path = self.get_project_root_path() @@ -1011,6 +1038,7 @@ def _load_from_dictionary( section_behavior: str = ProjectConfigDefault.DEFAULT_SECTION_BEHAVIOR statistics_generator: Optional[str] = None document_line_width: Optional[int] = None + allow_missing_relation_requirements: bool = False if "project" in config_dict: project_content = config_dict["project"] @@ -1106,6 +1134,14 @@ def _load_from_dictionary( "document_line_width", document_line_width ) + allow_missing_relation_requirements = project_content.get( + "allow_missing_relation_requirements", + project_content.get( + "allow_missing_parent_requirements", + allow_missing_relation_requirements, + ), + ) + if "source_nodes" in project_content: source_nodes_config = project_content["source_nodes"] assert isinstance(source_nodes_config, list) @@ -1163,5 +1199,6 @@ def _load_from_dictionary( section_behavior=section_behavior, statistics_generator=statistics_generator, document_line_width=document_line_width, + allow_missing_relation_requirements=allow_missing_relation_requirements, _config_last_update=config_last_update, ) diff --git a/strictdoc/core/query_engine/grammar.py b/strictdoc/core/query_engine/grammar.py index 80db8d8c2..7a33db031 100644 --- a/strictdoc/core/query_engine/grammar.py +++ b/strictdoc/core/query_engine/grammar.py @@ -37,6 +37,10 @@ | NodeHasChildRequirementsExpression | + NodeHasMissingRelationsExpression + | + NodeHasMissingParentRelationsExpression + | InExpression | NotInExpression @@ -92,6 +96,14 @@ _ = 'node.has_child_requirements' ; +NodeHasMissingRelationsExpression: + _ = 'node.has_missing_relations' +; + +NodeHasMissingParentRelationsExpression: + _ = 'node.has_missing_parent_relations' +; + NodeIsRequirementExpression: _ = 'node.is_requirement' '()'? ; diff --git a/strictdoc/core/query_engine/query_object.py b/strictdoc/core/query_engine/query_object.py index d0b692d88..5f45727f7 100644 --- a/strictdoc/core/query_engine/query_object.py +++ b/strictdoc/core/query_engine/query_object.py @@ -62,6 +62,16 @@ def __init__(self, parent: Any, _: Any): self.parent: Any = parent +class NodeHasMissingParentRelationsExpression: + def __init__(self, parent: Any, _: Any): + self.parent: Any = parent + + +class NodeHasMissingRelationsExpression: + def __init__(self, parent: Any, _: Any): + self.parent: Any = parent + + class NodeIsRequirementExpression: def __init__(self, parent: Any, _: Any): self.parent: Any = parent @@ -176,6 +186,10 @@ def _evaluate(self, node: SDocExtendedElementIF, expression: Any) -> bool: return self._evaluate_node_has_parent_requirements(node) if isinstance(expression, NodeHasChildRequirementsExpression): return self._evaluate_node_has_child_requirements(node) + if isinstance(expression, NodeHasMissingRelationsExpression): + return self._evaluate_node_has_missing_relations(node) + if isinstance(expression, NodeHasMissingParentRelationsExpression): + return self._evaluate_node_has_missing_relations(node) if isinstance(expression, NodeIsRequirementExpression): return ( isinstance(node, SDocNode) and node.node_type == "REQUIREMENT" @@ -312,6 +326,17 @@ def _evaluate_node_has_child_requirements( ) return self.traceability_index.has_children_requirements(node) + def _evaluate_node_has_missing_relations( + self, node: SDocExtendedElementIF + ) -> bool: + if not isinstance(node, SDocNode): + raise TypeError( + f"node.has_missing_relations can be only called on " + f"Requirement objects, got: {node.__class__.__name__}. To fix " + f"the error, prepend your query with node.is_requirement." + ) + return self.traceability_index.has_missing_relations_for_requirement(node) + def _evaluate_node_contains( self, node: SDocExtendedElementIF, diff --git a/strictdoc/core/query_engine/query_reader.py b/strictdoc/core/query_engine/query_reader.py index 04d46ad4e..6a691d004 100644 --- a/strictdoc/core/query_engine/query_reader.py +++ b/strictdoc/core/query_engine/query_reader.py @@ -15,6 +15,8 @@ NodeContainsExpression, NodeFieldExpression, NodeHasChildRequirementsExpression, + NodeHasMissingRelationsExpression, + NodeHasMissingParentRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, @@ -40,6 +42,8 @@ NodeContainsAnyFreeTextExpression, NodeFieldExpression, NodeHasChildRequirementsExpression, + NodeHasMissingRelationsExpression, + NodeHasMissingParentRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index 24bef0007..cf98186e7 100644 --- a/strictdoc/core/traceability_index.py +++ b/strictdoc/core/traceability_index.py @@ -297,6 +297,112 @@ def get_parent_requirements(self, requirement: SDocNode) -> List[SDocNode]: ) ) + def get_missing_relations( + self, requirement: SDocNode + ) -> List[Union[ParentReqReference, ChildReqReference]]: + assert isinstance(requirement, SDocNode) + + missing_relations: List[Union[ParentReqReference, ChildReqReference]] = [] + for relation_ in requirement.relations: + if not isinstance( + relation_, (ParentReqReference, ChildReqReference) + ): + continue + + if self.graph_database.get_link_value_weak( + link_type=GraphLinkType.UID_TO_NODE, + lhs_node=relation_.ref_uid, + ) is None: + missing_relations.append(relation_) + + return missing_relations + + def has_missing_relations_for_requirement( + self, requirement: SDocNode + ) -> bool: + assert isinstance(requirement, SDocNode) + return len(self.get_missing_relations(requirement)) > 0 + + def get_missing_relations_for_document( + self, document: SDocDocument + ) -> List[Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]]]: + assert isinstance(document, SDocDocument) + + missing_relations: List[ + Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]] + ] = [] + document_iterator = self.document_iterators[document] + for node_, _ in document_iterator.all_content(print_fragments=False): + if not isinstance(node_, SDocNode): + continue + for relation_ in self.get_missing_relations(node_): + missing_relations.append((node_, relation_)) + return missing_relations + + def get_all_missing_relations( + self, + ) -> List[Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]]]: + missing_relations: List[ + Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]] + ] = [] + for document_ in self.document_tree.document_list: + missing_relations.extend( + self.get_missing_relations_for_document(document_) + ) + return missing_relations + + def has_missing_relations(self) -> bool: + return len(self.get_all_missing_relations()) > 0 + + # Compatibility wrappers for old API. + def get_missing_parent_relations( + self, requirement: SDocNode + ) -> List[ParentReqReference]: + return list( + filter( + lambda relation_: isinstance(relation_, ParentReqReference), + self.get_missing_relations(requirement), + ) + ) + + def get_missing_child_relations( + self, requirement: SDocNode + ) -> List[ChildReqReference]: + return list( + filter( + lambda relation_: isinstance(relation_, ChildReqReference), + self.get_missing_relations(requirement), + ) + ) + + def has_missing_parent_relations_for_requirement( + self, requirement: SDocNode + ) -> bool: + return len(self.get_missing_parent_relations(requirement)) > 0 + + def get_missing_parent_relations_for_document( + self, document: SDocDocument + ) -> List[Tuple[SDocNode, ParentReqReference]]: + return list( + filter( + lambda pair_: isinstance(pair_[1], ParentReqReference), + self.get_missing_relations_for_document(document), + ) + ) + + def get_all_missing_parent_relations( + self, + ) -> List[Tuple[SDocNode, ParentReqReference]]: + return list( + filter( + lambda pair_: isinstance(pair_[1], ParentReqReference), + self.get_all_missing_relations(), + ) + ) + + def has_missing_parent_relations(self) -> bool: + return len(self.get_all_missing_parent_relations()) > 0 + def get_parent_relations_with_roles( self, node: SDocNode ) -> List[Tuple[SDocNode, Optional[str]]]: diff --git a/strictdoc/core/traceability_index_builder.py b/strictdoc/core/traceability_index_builder.py index 5dfdcd655..8b15e3e25 100644 --- a/strictdoc/core/traceability_index_builder.py +++ b/strictdoc/core/traceability_index_builder.py @@ -639,6 +639,10 @@ def create_from_document_tree( lhs_node=parent_reference.ref_uid, ) if parent_requirement is None: + if ( + project_config.allow_missing_relation_requirements + ): + continue raise StrictDocException( f"[DocumentIndex.create] " f"Requirement {requirement.reserved_uid} " @@ -686,6 +690,10 @@ def create_from_document_tree( lhs_node=child_reference.ref_uid, ) if child_requirement is None: + if ( + project_config.allow_missing_relation_requirements + ): + continue raise StrictDocException( f"[DocumentIndex.create] " f"Requirement {requirement.reserved_uid} " diff --git a/strictdoc/features/export/export_action.py b/strictdoc/features/export/export_action.py index 5c36074ab..99b421702 100644 --- a/strictdoc/features/export/export_action.py +++ b/strictdoc/features/export/export_action.py @@ -51,8 +51,50 @@ def build_index(self) -> TraceabilityIndex: print(exc.to_print_message()) # noqa: T201 sys.exit(1) self.traceability_index = traceability_index + self._print_missing_relations_warning() return traceability_index + def _print_missing_relations_warning(self) -> None: + if not self.project_config.allow_missing_relation_requirements: + return + + missing_relations = self.traceability_index.get_all_missing_relations() + if len(missing_relations) == 0: + return + + print( # noqa: T201 + "warning: unresolved requirement relations were " + "detected and allowed by configuration." + ) + print( # noqa: T201 + f"warning: missing relations count: " f"{len(missing_relations)}" + ) + + for requirement_, relation_ in missing_relations: + requirement_identifier = ( + requirement_.reserved_uid + if requirement_.reserved_uid is not None + else requirement_.reserved_mid + ) + document = requirement_.get_document() + document_title = ( + document.reserved_title + if isinstance(document, SDocDocument) + else "UNKNOWN_DOCUMENT" + ) + role_message = ( + f", role '{relation_.role}'" + if relation_.role is not None + else "" + ) + print( # noqa: T201 + "warning: " + f"requirement '{requirement_identifier}' in document " + f"'{document_title}' has a missing relation of type " + f"'{relation_.ref_type}' to requirement " + f"'{relation_.ref_uid}'{role_message}." + ) + @timing_decorator("Export SDoc") def export(self) -> None: assert self.traceability_index is not None, ( diff --git a/strictdoc/features/project_statistics/generator.py b/strictdoc/features/project_statistics/generator.py index b925ab2ad..7b83c3170 100644 --- a/strictdoc/features/project_statistics/generator.py +++ b/strictdoc/features/project_statistics/generator.py @@ -87,6 +87,11 @@ def export( ): document_tree_stats.requirements_no_links += 1 + if traceability_index.has_missing_relations_for_requirement( + requirement + ): + document_tree_stats.requirements_missing_relations += 1 + # RATIONALE. if ( requirement.ordered_fields_lookup.get("RATIONALE") @@ -216,6 +221,13 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) + section.metrics.append( + Metric( + name="Requirements with missing relations", + value=str(document_tree_stats.requirements_missing_relations), + link="search?q=(node.is_requirement() and node.has_missing_relations)", + ) + ) section.metrics.append( Metric( name="Requirements with no RATIONALE", diff --git a/strictdoc/features/project_statistics/models/project_tree_stats.py b/strictdoc/features/project_statistics/models/project_tree_stats.py index 7c2a81acc..8313ca17b 100644 --- a/strictdoc/features/project_statistics/models/project_tree_stats.py +++ b/strictdoc/features/project_statistics/models/project_tree_stats.py @@ -45,6 +45,7 @@ class DocumentTreeStats: requirements_no_uid: int = 0 requirements_no_links: int = 0 requirements_root_no_links: int = 0 + requirements_missing_relations: int = 0 requirements_no_rationale: int = 0 # STATUS. diff --git a/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py b/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py index 373001283..ee2a1bb11 100644 --- a/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py +++ b/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py @@ -31,6 +31,7 @@ class Test(E2ECase): "search-non-root-level-requirements-not-connected-to-any-parent-requirement", 4, ), + ("search-requirements-with-missing-parent-relations", 0), ("search-requirements-with-no-rationale", 4), ("search-requirements-with-status-none", 1), ("search-requirements-with-status-active", 1), diff --git a/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/input.sdoc b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/input.sdoc new file mode 100644 index 000000000..80ac27f64 --- /dev/null +++ b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/input.sdoc @@ -0,0 +1,15 @@ +[DOCUMENT] +TITLE: Hello world doc + +[REQUIREMENT] +UID: REQ-001 +TITLE: Existing requirement +STATEMENT: Existing requirement statement. + +[REQUIREMENT] +UID: REQ-002 +TITLE: Requirement with missing parent relation +STATEMENT: This requirement references a parent that does not exist. +RELATIONS: +- TYPE: Parent + VALUE: REQ-404 diff --git a/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/strictdoc.toml b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/strictdoc.toml new file mode 100644 index 000000000..ca4b74b5f --- /dev/null +++ b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/strictdoc.toml @@ -0,0 +1,8 @@ +[project] +title = "Test project" + +features = [ + "PROJECT_STATISTICS_SCREEN", +] + +allow_missing_relation_requirements = true diff --git a/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/test.itest b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/test.itest new file mode 100644 index 000000000..2d1968bdd --- /dev/null +++ b/tests/integration/features/project_statistics/40_requirements_with_missing_parent_relations/test.itest @@ -0,0 +1,15 @@ +# +# This test verifies the following functionality: +# Project statistics include a metric that counts requirements +# with missing relations when this behavior is enabled. +# + +RUN: %strictdoc export %S --output-dir %T | filecheck %s --dump-input=fail +CHECK: Published: Hello world doc +CHECK: warning: unresolved parent requirement relations were detected and allowed by configuration. + +RUN: %check_exists --file "%T/html/project_statistics.html" + +RUN: %cat "%T/html/project_statistics.html" | filecheck %s --dump-input=fail --check-prefix CHECK-HTML +CHECK-HTML: Requirements with missing relations +CHECK-HTML: 1 diff --git a/tests/unit/strictdoc/core/query_engine/test_query_reader.py b/tests/unit/strictdoc/core/query_engine/test_query_reader.py index ca1386817..e41d71dc7 100644 --- a/tests/unit/strictdoc/core/query_engine/test_query_reader.py +++ b/tests/unit/strictdoc/core/query_engine/test_query_reader.py @@ -2,6 +2,8 @@ EqualExpression, InExpression, NodeContainsAnyFreeTextExpression, + NodeHasMissingRelationsExpression, + NodeHasMissingParentRelationsExpression, NodeFieldExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, @@ -131,6 +133,30 @@ def test_62_node_is_requirement(): assert isinstance(query_object.root_expression, NodeIsRequirementExpression) +def test_62_1_node_has_missing_parent_relations(): + query = """\ +node.has_missing_parent_relations\ +""" + query_object = QueryReader.read(query) + assert isinstance(query_object, Query) + assert isinstance( + query_object.root_expression, + NodeHasMissingParentRelationsExpression, + ) + + +def test_62_2_node_has_missing_relations(): + query = """\ +node.has_missing_relations\ +""" + query_object = QueryReader.read(query) + assert isinstance(query_object, Query) + assert isinstance( + query_object.root_expression, + NodeHasMissingRelationsExpression, + ) + + def test_63_node_is_section(): query = """\ node.is_section\ diff --git a/tests/unit/strictdoc/core/test_traceability_index.py b/tests/unit/strictdoc/core/test_traceability_index.py index dfeb6ad03..e9e68cb60 100644 --- a/tests/unit/strictdoc/core/test_traceability_index.py +++ b/tests/unit/strictdoc/core/test_traceability_index.py @@ -2,9 +2,12 @@ @relation(SDOC-SRS-28, scope=file) """ +import pytest + from strictdoc.core.document_tree import DocumentTree from strictdoc.core.traceability_index import TraceabilityIndex from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder +from strictdoc.helpers.exception import StrictDocException from strictdoc.helpers.mid import MID from tests.unit.helpers.document_builder import DocumentBuilder @@ -394,3 +397,114 @@ def test_get_node_by_mid(): traceability_index.get_node_by_mid(MID(document_1.reserved_mid)) == document_1 ) + + +def test_missing_parent_relation_strict_mode_raises(): + document_builder = DocumentBuilder() + document_builder.add_requirement("REQ-001") + document_builder.add_requirement("REQ-002") + document_builder.add_requirement_relation( + relation_type="Parent", + source_requirement_id="REQ-002", + target_requirement_id="REQ-DOES-NOT-EXIST", + role=None, + ) + document = document_builder.build() + + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + + with pytest.raises(StrictDocException) as exception_info: + TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert "references parent requirement which doesn't exist" in str( + exception_info.value + ) + + +def test_missing_parent_relation_relaxed_mode_collects_diagnostics(): + document_builder = DocumentBuilder() + document_builder.project_config.allow_missing_relation_requirements = True + + document_builder.add_requirement("REQ-001") + requirement2 = document_builder.add_requirement("REQ-002") + document_builder.add_requirement_relation( + relation_type="Parent", + source_requirement_id="REQ-002", + target_requirement_id="REQ-DOES-NOT-EXIST", + role="Refines", + ) + + document = document_builder.build() + + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + + traceability_index = TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert traceability_index.has_missing_parent_relations() + assert traceability_index.get_parent_requirements(requirement2) == [] + + missing_relations = traceability_index.get_missing_parent_relations( + requirement2 + ) + assert len(missing_relations) == 1 + assert missing_relations[0].ref_uid == "REQ-DOES-NOT-EXIST" + assert missing_relations[0].role == "Refines" + + missing_document_relations = ( + traceability_index.get_missing_parent_relations_for_document(document) + ) + assert len(missing_document_relations) == 1 + assert missing_document_relations[0][0] == requirement2 + assert missing_document_relations[0][1].ref_uid == "REQ-DOES-NOT-EXIST" + + +def test_missing_child_relation_relaxed_mode_collects_diagnostics(): + document_builder = DocumentBuilder() + document_builder.project_config.allow_missing_relation_requirements = True + + requirement1 = document_builder.add_requirement("REQ-001") + document_builder.add_requirement_relation( + relation_type="Child", + source_requirement_id="REQ-001", + target_requirement_id="REQ-DOES-NOT-EXIST", + role="Validates", + ) + + document = document_builder.build() + + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + + traceability_index = TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert traceability_index.has_missing_relations() + assert traceability_index.get_children_requirements(requirement1) == [] + + missing_relations = traceability_index.get_missing_relations(requirement1) + assert len(missing_relations) == 1 + assert missing_relations[0].ref_uid == "REQ-DOES-NOT-EXIST" + assert missing_relations[0].role == "Validates" + assert missing_relations[0].ref_type == "Child" From 7721c6d0956419ffad9966cf41f66d292b30d2fa Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Thu, 18 Jun 2026 12:48:01 -0300 Subject: [PATCH 02/10] feat(html): show missing relation warnings across views --- .../html/_static/requirement__temporary.css | 23 ++++++++ ...issing_parent_relations_warning.jinja.html | 7 +++ ...ssing_relations_project_warning.jinja.html | 7 +++ .../components/node_field/links/index.jinja | 28 +++++++++- .../screens/document/document/main.jinja | 1 + .../screens/document/table/main.jinja | 55 +++++++++++++++---- .../templates/features/html2pdf/main.jinja | 1 + .../features/project_index/main.jinja | 1 + 8 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html create mode 100644 strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html diff --git a/strictdoc/export/html/_static/requirement__temporary.css b/strictdoc/export/html/_static/requirement__temporary.css index 24122fe33..78f388411 100644 --- a/strictdoc/export/html/_static/requirement__temporary.css +++ b/strictdoc/export/html/_static/requirement__temporary.css @@ -75,6 +75,29 @@ a.requirement__link-child::before { font-weight: bold; } +sdoc-warning-banner { + display: block; + margin: 0 0 0; + padding: 0.75rem 1rem; + border: 1px solid #f0c36d; + background: #fff6e5; + color: #5f3b00; + border-radius: 4px; +} + +.requirement__link-missing { + color: #5f3b00; + background: #fff6e5; + border: 1px solid #f0c36d; + border-radius: 4px; + padding: 0.15rem 0.4rem; +} + +.requirement__link li > .requirement__link-missing { + margin-left: 0; + width: 100%; +} + /* switch (injected by JS) */ .std-switch { diff --git a/strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html new file mode 100644 index 000000000..e772fff49 --- /dev/null +++ b/strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html @@ -0,0 +1,7 @@ +{%- set missing_relations = view_object.traceability_index.get_missing_relations_for_document(view_object.document) -%} +{%- if missing_relations | length > 0 -%} + + Warning: + {{ missing_relations | length }} relation(s) could not be resolved in this document. + +{%- endif -%} \ No newline at end of file diff --git a/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html new file mode 100644 index 000000000..8ff433aaf --- /dev/null +++ b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html @@ -0,0 +1,7 @@ +{%- set missing_relations = view_object.traceability_index.get_all_missing_relations() -%} +{%- if missing_relations | length > 0 -%} + + Warning: + {{ missing_relations | length }} relation(s) could not be resolved in this project. + +{%- endif -%} \ No newline at end of file diff --git a/strictdoc/export/html/templates/components/node_field/links/index.jinja b/strictdoc/export/html/templates/components/node_field/links/index.jinja index c413c77be..e79688310 100644 --- a/strictdoc/export/html/templates/components/node_field/links/index.jinja +++ b/strictdoc/export/html/templates/components/node_field/links/index.jinja @@ -1,6 +1,8 @@ {# needs sdoc_entity, see README.txt #} {%- set current_sdoc_entity = sdoc_entity -%} - {%- if view_object.traceability_index.has_parent_requirements(sdoc_entity) %} + {%- set missing_parent_relations = view_object.traceability_index.get_missing_parent_relations(current_sdoc_entity) -%} + {%- set missing_child_relations = view_object.traceability_index.get_missing_child_relations(current_sdoc_entity) -%} + {%- if view_object.traceability_index.has_parent_requirements(sdoc_entity) or missing_parent_relations | length > 0 %} RELATIONS (Parent): {%- endif %} - {%- if view_object.traceability_index.has_children_requirements(sdoc_entity) %} + {%- if view_object.traceability_index.has_children_requirements(sdoc_entity) or missing_child_relations | length > 0 %} RELATIONS (Child): {%- endif %} diff --git a/strictdoc/export/html/templates/screens/document/document/main.jinja b/strictdoc/export/html/templates/screens/document/document/main.jinja index 2cb56d6e7..898ca7d68 100644 --- a/strictdoc/export/html/templates/screens/document/document/main.jinja +++ b/strictdoc/export/html/templates/screens/document/document/main.jinja @@ -1,3 +1,4 @@
+ {% include "_shared/missing_parent_relations_warning.jinja.html" %} {% include "screens/document/document/frame_document_content.jinja.html" %}
diff --git a/strictdoc/export/html/templates/screens/document/table/main.jinja b/strictdoc/export/html/templates/screens/document/table/main.jinja index 3af09fcad..7b7ebddd7 100644 --- a/strictdoc/export/html/templates/screens/document/table/main.jinja +++ b/strictdoc/export/html/templates/screens/document/table/main.jinja @@ -1,5 +1,6 @@ {%- if view_object.has_any_nodes() -%}
+ {% include "_shared/missing_parent_relations_warning.jinja.html" %} {% include "_shared/tags.jinja.html" %}
@@ -46,38 +47,72 @@ {%- endfor -%} + {%- set missing_parent_relations = view_object.traceability_index.get_missing_parent_relations(requirement) %} + {%- set missing_child_relations = view_object.traceability_index.get_missing_child_relations(requirement) %} {%- if view_object.traceability_index.has_parent_requirements(requirement) %} Parents: {%- endif %} + {%- if missing_parent_relations | length > 0 %} + Missing Parents: + + {%- endif %} {%- if view_object.traceability_index.has_children_requirements(requirement) %} Children: {%- endif %} + {%- if missing_child_relations | length > 0 %} + Missing Children: + + {%- endif %} {%- if view_object.project_config.is_activated_requirements_to_source_traceability() -%} {%- set requirement_file_links = view_object.traceability_index.get_requirement_file_links(requirement) %} {%- if requirement_file_links %} diff --git a/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja b/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja index b107032eb..a96c4e231 100644 --- a/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja +++ b/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja @@ -4,6 +4,7 @@ html2pdf-preloader class="main" > + {% include "_shared/missing_parent_relations_warning.jinja.html" %}
+ {% include "_shared/missing_relations_project_warning.jinja.html" %}
{% include "features/project_index/frame_project_tree.jinja.html" %} From 3a373b8a24cfbd0d6a46bf631b07f09a079acfd7 Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Thu, 18 Jun 2026 13:15:44 -0300 Subject: [PATCH 03/10] refactor(traceability): remove missing-parent compatibility aliases --- strictdoc/commands/export_config.py | 6 +--- strictdoc/commands/server_config.py | 6 +--- strictdoc/core/project_config.py | 18 +----------- strictdoc/core/query_engine/grammar.py | 6 ---- strictdoc/core/query_engine/query_object.py | 7 ----- strictdoc/core/query_engine/query_reader.py | 2 -- strictdoc/core/traceability_index.py | 28 ------------------- ...l => missing_relations_warning.jinja.html} | 0 .../screens/document/document/main.jinja | 2 +- .../screens/document/table/main.jinja | 2 +- .../templates/features/html2pdf/main.jinja | 2 +- .../core/query_engine/test_query_reader.py | 15 +--------- .../strictdoc/core/test_traceability_index.py | 10 +++---- 13 files changed, 12 insertions(+), 92 deletions(-) rename strictdoc/export/html/templates/_shared/{missing_parent_relations_warning.jinja.html => missing_relations_warning.jinja.html} (100%) diff --git a/strictdoc/commands/export_config.py b/strictdoc/commands/export_config.py index 2829fb92f..3946fe3e7 100644 --- a/strictdoc/commands/export_config.py +++ b/strictdoc/commands/export_config.py @@ -31,7 +31,6 @@ def __init__( generate_diff_dirs: Optional[Tuple[str, str]], chromedriver: Optional[str], allow_missing_relation_requirements: bool = False, - allow_missing_parent_requirements: bool = False, ): assert isinstance(input_paths, list), f"{input_paths}" self.debug: bool = debug @@ -54,10 +53,7 @@ def __init__( self.generate_diff_git: Optional[str] = generate_diff_git self.generate_diff_dirs: Optional[Tuple[str, str]] = generate_diff_dirs self.chromedriver: Optional[str] = chromedriver - self.allow_missing_relation_requirements: bool = ( - allow_missing_relation_requirements - or allow_missing_parent_requirements - ) + self.allow_missing_relation_requirements: bool = allow_missing_relation_requirements def get_path_to_config(self) -> str: # FIXME: The control flow can be improved. diff --git a/strictdoc/commands/server_config.py b/strictdoc/commands/server_config.py index 0f256c503..cf20e248d 100644 --- a/strictdoc/commands/server_config.py +++ b/strictdoc/commands/server_config.py @@ -20,7 +20,6 @@ def __init__( host: Optional[str], port: Optional[int], allow_missing_relation_requirements: bool = False, - allow_missing_parent_requirements: bool = False, ): self.debug: bool = debug self.command: str = command @@ -30,10 +29,7 @@ def __init__( self.reload: bool = reload self.host: Optional[str] = host self.port: Optional[int] = port - self.allow_missing_relation_requirements: bool = ( - allow_missing_relation_requirements - or allow_missing_parent_requirements - ) + self.allow_missing_relation_requirements: bool = allow_missing_relation_requirements def get_full_input_path(self) -> str: return os.path.abspath(self._input_path) diff --git a/strictdoc/core/project_config.py b/strictdoc/core/project_config.py index 13e5758b4..bead476bc 100644 --- a/strictdoc/core/project_config.py +++ b/strictdoc/core/project_config.py @@ -154,7 +154,6 @@ def __init__( statistics_generator: Optional[str] = None, document_line_width: Optional[int] = None, allow_missing_relation_requirements: bool = False, - allow_missing_parent_requirements: Optional[bool] = None, # Logo path can be set in the project config to customize the launcher's appearance for a specific project. launcher_logo_path: Optional[str] = None, user_plugin: Optional[StrictDocPlugin] = None, @@ -396,22 +395,12 @@ def __init__( ) self.document_line_width: Optional[int] = document_line_width - if allow_missing_parent_requirements is not None: - allow_missing_relation_requirements = ( - allow_missing_relation_requirements - or allow_missing_parent_requirements - ) - assert isinstance( allow_missing_relation_requirements, bool ), allow_missing_relation_requirements self.allow_missing_relation_requirements: bool = ( allow_missing_relation_requirements ) - # Backward-compatible alias. - self.allow_missing_parent_requirements: bool = ( - self.allow_missing_relation_requirements - ) self.user_plugin: Optional[StrictDocPlugin] = user_plugin @@ -471,7 +460,6 @@ def integrate_server_config( if server_config.allow_missing_relation_requirements: self.allow_missing_relation_requirements = True - self.allow_missing_parent_requirements = True def integrate_export_config( self, export_config: ExportCommandConfig @@ -543,7 +531,6 @@ def integrate_export_config( if export_config.allow_missing_relation_requirements: self.allow_missing_relation_requirements = True - self.allow_missing_parent_requirements = True def validate_and_finalize(self) -> None: project_path = self.get_project_root_path() @@ -1136,10 +1123,7 @@ def _load_from_dictionary( allow_missing_relation_requirements = project_content.get( "allow_missing_relation_requirements", - project_content.get( - "allow_missing_parent_requirements", - allow_missing_relation_requirements, - ), + allow_missing_relation_requirements, ) if "source_nodes" in project_content: diff --git a/strictdoc/core/query_engine/grammar.py b/strictdoc/core/query_engine/grammar.py index 7a33db031..f84698541 100644 --- a/strictdoc/core/query_engine/grammar.py +++ b/strictdoc/core/query_engine/grammar.py @@ -39,8 +39,6 @@ | NodeHasMissingRelationsExpression | - NodeHasMissingParentRelationsExpression - | InExpression | NotInExpression @@ -100,10 +98,6 @@ _ = 'node.has_missing_relations' ; -NodeHasMissingParentRelationsExpression: - _ = 'node.has_missing_parent_relations' -; - NodeIsRequirementExpression: _ = 'node.is_requirement' '()'? ; diff --git a/strictdoc/core/query_engine/query_object.py b/strictdoc/core/query_engine/query_object.py index 5f45727f7..0446486b1 100644 --- a/strictdoc/core/query_engine/query_object.py +++ b/strictdoc/core/query_engine/query_object.py @@ -62,11 +62,6 @@ def __init__(self, parent: Any, _: Any): self.parent: Any = parent -class NodeHasMissingParentRelationsExpression: - def __init__(self, parent: Any, _: Any): - self.parent: Any = parent - - class NodeHasMissingRelationsExpression: def __init__(self, parent: Any, _: Any): self.parent: Any = parent @@ -188,8 +183,6 @@ def _evaluate(self, node: SDocExtendedElementIF, expression: Any) -> bool: return self._evaluate_node_has_child_requirements(node) if isinstance(expression, NodeHasMissingRelationsExpression): return self._evaluate_node_has_missing_relations(node) - if isinstance(expression, NodeHasMissingParentRelationsExpression): - return self._evaluate_node_has_missing_relations(node) if isinstance(expression, NodeIsRequirementExpression): return ( isinstance(node, SDocNode) and node.node_type == "REQUIREMENT" diff --git a/strictdoc/core/query_engine/query_reader.py b/strictdoc/core/query_engine/query_reader.py index 6a691d004..523e5a66e 100644 --- a/strictdoc/core/query_engine/query_reader.py +++ b/strictdoc/core/query_engine/query_reader.py @@ -16,7 +16,6 @@ NodeFieldExpression, NodeHasChildRequirementsExpression, NodeHasMissingRelationsExpression, - NodeHasMissingParentRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, @@ -43,7 +42,6 @@ NodeFieldExpression, NodeHasChildRequirementsExpression, NodeHasMissingRelationsExpression, - NodeHasMissingParentRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index cf98186e7..e23eca654 100644 --- a/strictdoc/core/traceability_index.py +++ b/strictdoc/core/traceability_index.py @@ -375,34 +375,6 @@ def get_missing_child_relations( ) ) - def has_missing_parent_relations_for_requirement( - self, requirement: SDocNode - ) -> bool: - return len(self.get_missing_parent_relations(requirement)) > 0 - - def get_missing_parent_relations_for_document( - self, document: SDocDocument - ) -> List[Tuple[SDocNode, ParentReqReference]]: - return list( - filter( - lambda pair_: isinstance(pair_[1], ParentReqReference), - self.get_missing_relations_for_document(document), - ) - ) - - def get_all_missing_parent_relations( - self, - ) -> List[Tuple[SDocNode, ParentReqReference]]: - return list( - filter( - lambda pair_: isinstance(pair_[1], ParentReqReference), - self.get_all_missing_relations(), - ) - ) - - def has_missing_parent_relations(self) -> bool: - return len(self.get_all_missing_parent_relations()) > 0 - def get_parent_relations_with_roles( self, node: SDocNode ) -> List[Tuple[SDocNode, Optional[str]]]: diff --git a/strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html similarity index 100% rename from strictdoc/export/html/templates/_shared/missing_parent_relations_warning.jinja.html rename to strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html diff --git a/strictdoc/export/html/templates/screens/document/document/main.jinja b/strictdoc/export/html/templates/screens/document/document/main.jinja index 898ca7d68..732bc7e7d 100644 --- a/strictdoc/export/html/templates/screens/document/document/main.jinja +++ b/strictdoc/export/html/templates/screens/document/document/main.jinja @@ -1,4 +1,4 @@
- {% include "_shared/missing_parent_relations_warning.jinja.html" %} + {% include "_shared/missing_relations_warning.jinja.html" %} {% include "screens/document/document/frame_document_content.jinja.html" %}
diff --git a/strictdoc/export/html/templates/screens/document/table/main.jinja b/strictdoc/export/html/templates/screens/document/table/main.jinja index 7b7ebddd7..4db783fd4 100644 --- a/strictdoc/export/html/templates/screens/document/table/main.jinja +++ b/strictdoc/export/html/templates/screens/document/table/main.jinja @@ -1,6 +1,6 @@ {%- if view_object.has_any_nodes() -%}
- {% include "_shared/missing_parent_relations_warning.jinja.html" %} + {% include "_shared/missing_relations_warning.jinja.html" %} {% include "_shared/tags.jinja.html" %}
diff --git a/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja b/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja index a96c4e231..6a10340b1 100644 --- a/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja +++ b/strictdoc/features/html2pdf/templates/features/html2pdf/main.jinja @@ -4,7 +4,7 @@ html2pdf-preloader class="main" > - {% include "_shared/missing_parent_relations_warning.jinja.html" %} + {% include "_shared/missing_relations_warning.jinja.html" %}
Date: Thu, 18 Jun 2026 13:47:07 -0300 Subject: [PATCH 04/10] fix(project_statistics): show missing relations metric only in relaxed mode --- docs/sdoc_project_statistics.py | 15 +++++++++------ .../features/project_statistics/generator.py | 15 +++++++++------ .../view_project_statistics/test_case.py | 1 - .../01_statistics_are_generated/test.itest | 1 + 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/sdoc_project_statistics.py b/docs/sdoc_project_statistics.py index 720ae9aef..b858231db 100644 --- a/docs/sdoc_project_statistics.py +++ b/docs/sdoc_project_statistics.py @@ -260,13 +260,16 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) - section.metrics.append( - Metric( - name="Requirements with missing relations", - value=str(document_tree_stats.requirements_missing_relations), - link="search?q=(node.is_requirement() and node.has_missing_relations)", + if project_config.allow_missing_relation_requirements: + section.metrics.append( + Metric( + name="Requirements with missing relations", + value=str( + document_tree_stats.requirements_missing_relations + ), + link="search?q=(node.is_requirement() and node.has_missing_relations)", + ) ) - ) section.metrics.append( Metric( name="Requirements with no RATIONALE", diff --git a/strictdoc/features/project_statistics/generator.py b/strictdoc/features/project_statistics/generator.py index 7b83c3170..9b79ce461 100644 --- a/strictdoc/features/project_statistics/generator.py +++ b/strictdoc/features/project_statistics/generator.py @@ -221,13 +221,16 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) - section.metrics.append( - Metric( - name="Requirements with missing relations", - value=str(document_tree_stats.requirements_missing_relations), - link="search?q=(node.is_requirement() and node.has_missing_relations)", + if project_config.allow_missing_relation_requirements: + section.metrics.append( + Metric( + name="Requirements with missing relations", + value=str( + document_tree_stats.requirements_missing_relations + ), + link="search?q=(node.is_requirement() and node.has_missing_relations)", + ) ) - ) section.metrics.append( Metric( name="Requirements with no RATIONALE", diff --git a/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py b/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py index ee2a1bb11..373001283 100644 --- a/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py +++ b/tests/end2end/screens/project_statistics/view_project_statistics/test_case.py @@ -31,7 +31,6 @@ class Test(E2ECase): "search-non-root-level-requirements-not-connected-to-any-parent-requirement", 4, ), - ("search-requirements-with-missing-parent-relations", 0), ("search-requirements-with-no-rationale", 4), ("search-requirements-with-status-none", 1), ("search-requirements-with-status-active", 1), diff --git a/tests/integration/features/project_statistics/01_statistics_are_generated/test.itest b/tests/integration/features/project_statistics/01_statistics_are_generated/test.itest index 057be4789..36b570c5d 100644 --- a/tests/integration/features/project_statistics/01_statistics_are_generated/test.itest +++ b/tests/integration/features/project_statistics/01_statistics_are_generated/test.itest @@ -15,4 +15,5 @@ RUN: %cat "%T/html/project_statistics.html" | filecheck %s --check-prefix CHECK- CHECK-HTML: Test project CHECK-HTML: Total documents CHECK-HTML: Total requirements +CHECK-HTML-NOT: Requirements with missing relations CHECK-HTML-NOT: Requirements status breakdown From 02fc5f1dde7058f82c60de90f8ac937d5a75424a Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Tue, 23 Jun 2026 14:05:26 -0300 Subject: [PATCH 05/10] refactor(tracebility): treat missing relations as issues, and add them to validation_index. --- strictdoc/core/file_traceability_index.py | 8 ++ strictdoc/core/traceability_index.py | 83 +++++++++++++++---- strictdoc/core/traceability_index_builder.py | 20 +++-- .../html/_static/requirement__temporary.css | 23 ----- ...ssing_relations_project_warning.jinja.html | 4 +- .../missing_relations_warning.jinja.html | 7 -- .../components/node_field/files/index.jinja | 8 +- .../components/node_field/links/index.jinja | 32 ++----- .../screens/document/document/main.jinja | 1 - .../screens/document/table/main.jinja | 31 +++++-- strictdoc/features/export/export_action.py | 12 ++- .../templates/features/html2pdf/main.jinja | 1 - 12 files changed, 132 insertions(+), 98 deletions(-) delete mode 100644 strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html diff --git a/strictdoc/core/file_traceability_index.py b/strictdoc/core/file_traceability_index.py index 311a15700..eb405552e 100644 --- a/strictdoc/core/file_traceability_index.py +++ b/strictdoc/core/file_traceability_index.py @@ -513,6 +513,14 @@ def validate_and_resolve( file_posix_path ) if source_file_traceability_info is None: + if project_config.allow_missing_relation_requirements: + traceability_index.validation_index.add_issue( + forward_requirement_, + issue=f"Missing file relation: {file_posix_path}", + field="RELATIONS (File)", + subject=f"Node: {forward_requirement_.reserved_title}", + ) + continue raise StrictDocException( f"Requirement {forward_requirement_.reserved_uid} " "references a file that does not exist: " diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index e23eca654..cfd6ae76a 100644 --- a/strictdoc/core/traceability_index.py +++ b/strictdoc/core/traceability_index.py @@ -4,7 +4,7 @@ import datetime from copy import copy, deepcopy -from typing import Any, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Dict, Generator, List, Optional, Tuple, Union, TypeGuard from strictdoc.backend.sdoc.document_reference import DocumentReference from strictdoc.backend.sdoc.models.anchor import Anchor @@ -15,6 +15,7 @@ from strictdoc.backend.sdoc.models.node import SDocNode from strictdoc.backend.sdoc.models.reference import ( ChildReqReference, + FileReference, ParentReqReference, ) from strictdoc.backend.sdoc.node_filter import NodeFilter @@ -299,21 +300,29 @@ def get_parent_requirements(self, requirement: SDocNode) -> List[SDocNode]: def get_missing_relations( self, requirement: SDocNode - ) -> List[Union[ParentReqReference, ChildReqReference]]: + ) -> List[Union[ParentReqReference, ChildReqReference, FileReference]]: assert isinstance(requirement, SDocNode) - missing_relations: List[Union[ParentReqReference, ChildReqReference]] = [] + missing_relations: List[ + Union[ParentReqReference, ChildReqReference, FileReference] + ] = [] for relation_ in requirement.relations: - if not isinstance( - relation_, (ParentReqReference, ChildReqReference) - ): + if isinstance(relation_, (ParentReqReference, ChildReqReference)): + if self.graph_database.get_link_value_weak( + link_type=GraphLinkType.UID_TO_NODE, + lhs_node=relation_.ref_uid, + ) is None: + missing_relations.append(relation_) continue - if self.graph_database.get_link_value_weak( - link_type=GraphLinkType.UID_TO_NODE, - lhs_node=relation_.ref_uid, - ) is None: - missing_relations.append(relation_) + if isinstance(relation_, FileReference): + if ( + self._file_traceability_index.get_coverage_info_weak( + relation_.get_posix_path() + ) + is None + ): + missing_relations.append(relation_) return missing_relations @@ -325,11 +334,23 @@ def has_missing_relations_for_requirement( def get_missing_relations_for_document( self, document: SDocDocument - ) -> List[Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]]]: + ) -> List[ + Tuple[ + SDocNode, + Union[ParentReqReference, ChildReqReference, FileReference], + ] + ]: assert isinstance(document, SDocDocument) missing_relations: List[ - Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]] + Tuple[ + SDocNode, + Union[ + ParentReqReference, + ChildReqReference, + FileReference, + ], + ] ] = [] document_iterator = self.document_iterators[document] for node_, _ in document_iterator.all_content(print_fragments=False): @@ -341,9 +362,21 @@ def get_missing_relations_for_document( def get_all_missing_relations( self, - ) -> List[Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]]]: + ) -> List[ + Tuple[ + SDocNode, + Union[ParentReqReference, ChildReqReference, FileReference], + ] + ]: missing_relations: List[ - Tuple[SDocNode, Union[ParentReqReference, ChildReqReference]] + Tuple[ + SDocNode, + Union[ + ParentReqReference, + ChildReqReference, + FileReference, + ], + ] ] = [] for document_ in self.document_tree.document_list: missing_relations.extend( @@ -358,9 +391,11 @@ def has_missing_relations(self) -> bool: def get_missing_parent_relations( self, requirement: SDocNode ) -> List[ParentReqReference]: + def is_parent_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[ParentReqReference]: + return isinstance(relation, ParentReqReference) return list( filter( - lambda relation_: isinstance(relation_, ParentReqReference), + is_parent_relation, self.get_missing_relations(requirement), ) ) @@ -368,9 +403,23 @@ def get_missing_parent_relations( def get_missing_child_relations( self, requirement: SDocNode ) -> List[ChildReqReference]: + def is_child_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[ChildReqReference]: + return isinstance(relation, ChildReqReference) + return list( + filter( + is_child_relation, + self.get_missing_relations(requirement), + ) + ) + + def get_missing_file_relations( + self, requirement: SDocNode + ) -> List[FileReference]: + def is_file_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[FileReference]: + return isinstance(relation, FileReference) return list( filter( - lambda relation_: isinstance(relation_, ChildReqReference), + is_file_relation, self.get_missing_relations(requirement), ) ) diff --git a/strictdoc/core/traceability_index_builder.py b/strictdoc/core/traceability_index_builder.py index 8b15e3e25..b698ddbe3 100644 --- a/strictdoc/core/traceability_index_builder.py +++ b/strictdoc/core/traceability_index_builder.py @@ -639,9 +639,13 @@ def create_from_document_tree( lhs_node=parent_reference.ref_uid, ) if parent_requirement is None: - if ( - project_config.allow_missing_relation_requirements - ): + if project_config.allow_missing_relation_requirements: + traceability_index.validation_index.add_issue( + requirement, + issue=f"Missing parent relation: {parent_reference.ref_uid}", + field="RELATIONS (Parent)", + subject=f"Node: {requirement.reserved_title}", + ) continue raise StrictDocException( f"[DocumentIndex.create] " @@ -690,9 +694,13 @@ def create_from_document_tree( lhs_node=child_reference.ref_uid, ) if child_requirement is None: - if ( - project_config.allow_missing_relation_requirements - ): + if project_config.allow_missing_relation_requirements: + traceability_index.validation_index.add_issue( + requirement, + issue=f"Missing child relation: {child_reference.ref_uid}", + field="RELATIONS (Child)", + subject=f"Node: {requirement.reserved_title}", + ) continue raise StrictDocException( f"[DocumentIndex.create] " diff --git a/strictdoc/export/html/_static/requirement__temporary.css b/strictdoc/export/html/_static/requirement__temporary.css index 78f388411..24122fe33 100644 --- a/strictdoc/export/html/_static/requirement__temporary.css +++ b/strictdoc/export/html/_static/requirement__temporary.css @@ -75,29 +75,6 @@ a.requirement__link-child::before { font-weight: bold; } -sdoc-warning-banner { - display: block; - margin: 0 0 0; - padding: 0.75rem 1rem; - border: 1px solid #f0c36d; - background: #fff6e5; - color: #5f3b00; - border-radius: 4px; -} - -.requirement__link-missing { - color: #5f3b00; - background: #fff6e5; - border: 1px solid #f0c36d; - border-radius: 4px; - padding: 0.15rem 0.4rem; -} - -.requirement__link li > .requirement__link-missing { - margin-left: 0; - width: 100%; -} - /* switch (injected by JS) */ .std-switch { diff --git a/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html index 8ff433aaf..bab38fd9d 100644 --- a/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html +++ b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html @@ -1,7 +1,7 @@ {%- set missing_relations = view_object.traceability_index.get_all_missing_relations() -%} {%- if missing_relations | length > 0 -%} - +
Warning: {{ missing_relations | length }} relation(s) could not be resolved in this project. - +
{%- endif -%} \ No newline at end of file diff --git a/strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html deleted file mode 100644 index e772fff49..000000000 --- a/strictdoc/export/html/templates/_shared/missing_relations_warning.jinja.html +++ /dev/null @@ -1,7 +0,0 @@ -{%- set missing_relations = view_object.traceability_index.get_missing_relations_for_document(view_object.document) -%} -{%- if missing_relations | length > 0 -%} - - Warning: - {{ missing_relations | length }} relation(s) could not be resolved in this document. - -{%- endif -%} \ No newline at end of file diff --git a/strictdoc/export/html/templates/components/node_field/files/index.jinja b/strictdoc/export/html/templates/components/node_field/files/index.jinja index b9ce68900..fecc15c34 100644 --- a/strictdoc/export/html/templates/components/node_field/files/index.jinja +++ b/strictdoc/export/html/templates/components/node_field/files/index.jinja @@ -1,7 +1,8 @@ {# needs sdoc_entity, see README.txt #} {%- if view_object.project_config.is_activated_requirements_to_source_traceability() -%} {%- set requirement_file_links = view_object.traceability_index.get_requirement_file_links(sdoc_entity) %} - {%- if requirement_file_links is not none and requirement_file_links|length > 0 %} + {%- set has_missing_file_relations = view_object.traceability_index.get_missing_file_relations(sdoc_entity) | length > 0 %} + {%- if requirement_file_links or has_missing_file_relations %} RELATIONS (File): - {%- endif %} - {%- endif %} + {{ view_object.render_issues(sdoc_entity, "RELATIONS (File)") }} + {%- endif %} + {%- endif -%} diff --git a/strictdoc/export/html/templates/components/node_field/links/index.jinja b/strictdoc/export/html/templates/components/node_field/links/index.jinja index e79688310..ba65100a6 100644 --- a/strictdoc/export/html/templates/components/node_field/links/index.jinja +++ b/strictdoc/export/html/templates/components/node_field/links/index.jinja @@ -1,8 +1,7 @@ {# needs sdoc_entity, see README.txt #} {%- set current_sdoc_entity = sdoc_entity -%} - {%- set missing_parent_relations = view_object.traceability_index.get_missing_parent_relations(current_sdoc_entity) -%} - {%- set missing_child_relations = view_object.traceability_index.get_missing_child_relations(current_sdoc_entity) -%} - {%- if view_object.traceability_index.has_parent_requirements(sdoc_entity) or missing_parent_relations | length > 0 %} + {%- set has_missing_parent_relations = view_object.traceability_index.get_missing_parent_relations(current_sdoc_entity) | length > 0 -%} + {%- if view_object.traceability_index.has_parent_requirements(sdoc_entity) or has_missing_parent_relations %} RELATIONS (Parent): + {{ view_object.render_issues(sdoc_entity, "RELATIONS (Parent)") }} {%- endif %} - {%- if view_object.traceability_index.has_children_requirements(sdoc_entity) or missing_child_relations | length > 0 %} + {%- set has_missing_child_relations = view_object.traceability_index.get_missing_child_relations(current_sdoc_entity) | length > 0 -%} + {%- if view_object.traceability_index.has_children_requirements(sdoc_entity) or has_missing_child_relations %} RELATIONS (Child): + {{ view_object.render_issues(sdoc_entity, "RELATIONS (Child)") }} {%- endif %} diff --git a/strictdoc/export/html/templates/screens/document/document/main.jinja b/strictdoc/export/html/templates/screens/document/document/main.jinja index 732bc7e7d..2cb56d6e7 100644 --- a/strictdoc/export/html/templates/screens/document/document/main.jinja +++ b/strictdoc/export/html/templates/screens/document/document/main.jinja @@ -1,4 +1,3 @@
- {% include "_shared/missing_relations_warning.jinja.html" %} {% include "screens/document/document/frame_document_content.jinja.html" %}
diff --git a/strictdoc/export/html/templates/screens/document/table/main.jinja b/strictdoc/export/html/templates/screens/document/table/main.jinja index 4db783fd4..a5844a655 100644 --- a/strictdoc/export/html/templates/screens/document/table/main.jinja +++ b/strictdoc/export/html/templates/screens/document/table/main.jinja @@ -1,6 +1,5 @@ {%- if view_object.has_any_nodes() -%}
- {% include "_shared/missing_relations_warning.jinja.html" %} {% include "_shared/tags.jinja.html" %}
@@ -67,16 +66,15 @@ {%- endif %} {%- if missing_parent_relations | length > 0 %} Missing Parents: - {%- endif %} + {%- set missing_file_relations = view_object.traceability_index.get_missing_file_relations(requirement) %} + {%- if missing_file_relations | length > 0 %} + Missing Source files: + + {%- endif %} {%- endif %} From dd1a10c73fc35f31e045d2b83549cd30ad70f76f Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Tue, 23 Jun 2026 15:29:30 -0300 Subject: [PATCH 07/10] test: remove some case tests --- .../strictdoc/core/test_traceability_index.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/unit/strictdoc/core/test_traceability_index.py b/tests/unit/strictdoc/core/test_traceability_index.py index fa94dba4e..25510673d 100644 --- a/tests/unit/strictdoc/core/test_traceability_index.py +++ b/tests/unit/strictdoc/core/test_traceability_index.py @@ -2,11 +2,20 @@ @relation(SDOC-SRS-28, scope=file) """ +import pytest + +from strictdoc.backend.sdoc.models.reference import ( + ChildReqReference, + FileReference, + ParentReqReference, +) from strictdoc.core.document_tree import DocumentTree from strictdoc.core.traceability_index import TraceabilityIndex from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder +from strictdoc.helpers.exception import StrictDocException from strictdoc.helpers.mid import MID from tests.unit.helpers.document_builder import DocumentBuilder +from typing import List def test_valid_01_one_document_with_1req(): @@ -483,4 +492,33 @@ def test__delete_requirement__parent_child_links_cleaned_up_symmetrically(): # After deletion the stale NODE_TO_CHILD_NODES entry on REQ-001 must be # gone; previously it was left in place. - assert traceability_index.get_children_requirements(requirement1) == [] \ No newline at end of file + assert traceability_index.get_children_requirements(requirement1) == [] + +def test__missing_parent_relation__strict_mode_raises(): + document_builder = DocumentBuilder() + document_builder.add_requirement("REQ-001") + document_builder.add_requirement("REQ-002") + document_builder.add_requirement_relation( + relation_type="Parent", + source_requirement_id="REQ-002", + target_requirement_id="REQ-DOES-NOT-EXIST", + role=None, + ) + document = document_builder.build() + + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + + with pytest.raises(StrictDocException) as exception_info: + TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert "references parent requirement which doesn't exist" in str( + exception_info.value + ) From ae3479464f68e855d857154a9099adef9a7aafec Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Fri, 3 Jul 2026 12:18:41 -0300 Subject: [PATCH 08/10] feat(traceability): support unresolved inline links in relaxed mode --- strictdoc/backend/rst/writer.py | 16 +++++--- strictdoc/core/traceability_index.py | 38 ++++++++++++++++++ strictdoc/core/traceability_index_builder.py | 26 +++++++++++- .../export/html/renderers/markup_renderer.py | 24 +++++++---- ...ssing_relations_project_warning.jinja.html | 8 ++-- strictdoc/features/export/export_action.py | 40 +++++++++++++++++++ 6 files changed, 136 insertions(+), 16 deletions(-) diff --git a/strictdoc/backend/rst/writer.py b/strictdoc/backend/rst/writer.py index 6738413ec..95fe077be 100644 --- a/strictdoc/backend/rst/writer.py +++ b/strictdoc/backend/rst/writer.py @@ -127,15 +127,21 @@ def _print_node_field(self, object_with_parts: SDocNodeField) -> str: else: output += part elif isinstance(part, InlineLink): - node_or_none = self.index.get_linkable_node_by_uid(part.link) + node_or_none = self.index.get_linkable_node_by_uid_weak(part.link) # Labels that aren't placed before a section title can still be # referenced, but you must give the link an explicit title, # using this syntax: :ref:`Link title `. # https://www.sphinx-doc.org/en/master/usage/restructuredtext/roles.html - node_display_title = node_or_none.get_display_title( - include_toc_number=False - ) - output += f":ref:`{node_display_title} <{part.link}>`" + if node_or_none is not None: + node_display_title = node_or_none.get_display_title( + include_toc_number=False + ) + output += f":ref:`{node_display_title} <{part.link}>`" + else: + # Relaxed mode (allow_missing_relation_requirements): the + # inline link target does not exist. Render the target as + # plain text to avoid producing a broken :ref: reference. + output += part.link elif isinstance(part, Anchor): output += f".. _{part.value}:\n" else: diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index 6123c40e0..50beee3cf 100644 --- a/strictdoc/core/traceability_index.py +++ b/strictdoc/core/traceability_index.py @@ -388,6 +388,44 @@ def get_all_missing_relations( def has_missing_relations(self) -> bool: return len(self.get_all_missing_relations()) > 0 + def get_unresolved_inline_links( + self, node: SDocNode + ) -> List[InlineLink]: + assert isinstance(node, SDocNode) + + unresolved_inline_links: List[InlineLink] = [] + for node_field_ in node.enumerate_fields(): + for part_ in node_field_.parts: + if not isinstance(part_, InlineLink): + continue + if ( + self.graph_database.get_link_value_weak( + link_type=GraphLinkType.UID_TO_NODE, + lhs_node=part_.link, + ) + is None + ): + unresolved_inline_links.append(part_) + return unresolved_inline_links + + def get_all_unresolved_inline_links( + self, + ) -> List[Tuple[SDocNode, InlineLink]]: + unresolved_inline_links: List[Tuple[SDocNode, InlineLink]] = [] + for document_ in self.document_tree.document_list: + document_iterator = self.document_iterators[document_] + for node_, _ in document_iterator.all_content( + print_fragments=False + ): + if not isinstance(node_, SDocNode): + continue + for inline_link_ in self.get_unresolved_inline_links(node_): + unresolved_inline_links.append((node_, inline_link_)) + return unresolved_inline_links + + def has_unresolved_inline_links(self) -> bool: + return len(self.get_all_unresolved_inline_links()) > 0 + # Compatibility wrappers for old API. def get_missing_parent_relations( self, requirement: SDocNode diff --git a/strictdoc/core/traceability_index_builder.py b/strictdoc/core/traceability_index_builder.py index 1d517283b..0c76ad3e7 100644 --- a/strictdoc/core/traceability_index_builder.py +++ b/strictdoc/core/traceability_index_builder.py @@ -26,7 +26,7 @@ SDocElementIF, SDocNodeIF, ) -from strictdoc.backend.sdoc.models.node import SDocNode +from strictdoc.backend.sdoc.models.node import SDocNode, SDocNodeField from strictdoc.backend.sdoc.models.reference import ( ChildReqReference, ParentReqReference, @@ -257,6 +257,30 @@ def create( link_type=GraphLinkType.UID_TO_NODE, lhs_node=inline_link.link, ): + if project_config.allow_missing_relation_requirements: + # Relaxed mode: an inline link may reference a target that + # does not exist. Record a validation issue (which prints a + # warning and highlights the owning field) instead of + # failing the whole build. + inline_link_field = inline_link.parent + inline_link_node = inline_link.parent_node() + field_name: Optional[str] = ( + inline_link_field.field_name + if isinstance(inline_link_field, SDocNodeField) + else None + ) + node_title = ( + inline_link_node.reserved_title + if isinstance(inline_link_node, SDocNode) + else None + ) + traceability_index.validation_index.add_issue( + inline_link_node, + issue=f"Missing link: {inline_link.link}", + field=field_name, + subject=f"Node: {node_title}", + ) + continue raise StrictDocException( "DocumentIndex: " "the inline link references an object with an UID " diff --git a/strictdoc/export/html/renderers/markup_renderer.py b/strictdoc/export/html/renderers/markup_renderer.py index 9a82857f9..40b1288bd 100644 --- a/strictdoc/export/html/renderers/markup_renderer.py +++ b/strictdoc/export/html/renderers/markup_renderer.py @@ -198,14 +198,24 @@ def render_node_field( parts_output += part elif isinstance(part, InlineLink): linkable_node = ( - self.traceability_index.get_linkable_node_by_uid(part.link) - ) - href = self.link_renderer.render_node_link( - linkable_node, self.context_document, document_type - ) - parts_output += fragment_writer.write_anchor_link( - linkable_node.get_display_title(), href + self.traceability_index.get_linkable_node_by_uid_weak(part.link) ) + if linkable_node is not None: + href = self.link_renderer.render_node_link( + linkable_node, self.context_document, document_type + ) + parts_output += fragment_writer.write_anchor_link( + linkable_node.get_display_title(), href + ) + else: + # Relaxed mode (allow_missing_relation_requirements): the + # inline link references a target that does not exist. Render + # the link target as a dead link so that the generation does + # not fail. The missing link is highlighted separately as a + # validation issue on the owning field. + parts_output += fragment_writer.write_anchor_link( + part.link, "#" + ) elif isinstance(part, Anchor): parts_output += self.template_anchor.render( anchor=part, diff --git a/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html index bab38fd9d..931dd13e9 100644 --- a/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html +++ b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html @@ -1,7 +1,9 @@ -{%- set missing_relations = view_object.traceability_index.get_all_missing_relations() -%} -{%- if missing_relations | length > 0 -%} +{%- set missing_relations = view_object.traceability_index.get_all_missing_relations() | length -%} +{%- set missing_links = view_object.traceability_index.get_all_unresolved_inline_links() | length -%} +{%- set missing_tracebility = missing_relations + missing_links -%} +{%- if missing_tracebility > 0 -%}
Warning: - {{ missing_relations | length }} relation(s) could not be resolved in this project. + {{ missing_tracebility }} relation(s) or link(s) could not be resolved in this project.
{%- endif -%} \ No newline at end of file diff --git a/strictdoc/features/export/export_action.py b/strictdoc/features/export/export_action.py index e252dad79..5ce3c15fd 100644 --- a/strictdoc/features/export/export_action.py +++ b/strictdoc/features/export/export_action.py @@ -53,6 +53,7 @@ def build_index(self) -> TraceabilityIndex: sys.exit(1) self.traceability_index = traceability_index self._print_missing_relations_warning() + self._print_unresolved_inline_links_warning() return traceability_index def _print_missing_relations_warning(self) -> None: @@ -101,6 +102,45 @@ def _print_missing_relations_warning(self) -> None: f"'{relation_.ref_type}' to {relation_target}{role_message}." ) + def _print_unresolved_inline_links_warning(self) -> None: + if not self.project_config.allow_missing_relation_requirements: + return + + unresolved_inline_links = ( + self.traceability_index.get_all_unresolved_inline_links() + ) + if len(unresolved_inline_links) == 0: + return + + print( # noqa: T201 + "warning: unresolved inline links were " + "detected and allowed by configuration." + ) + print( # noqa: T201 + "warning: unresolved inline links count: " + f"{len(unresolved_inline_links)}" + ) + + for node_, inline_link_ in unresolved_inline_links: + node_identifier = ( + node_.reserved_uid + if node_.reserved_uid is not None + else node_.reserved_mid + ) + document = node_.get_document() + document_title = ( + document.reserved_title + if isinstance(document, SDocDocument) + else "UNKNOWN_DOCUMENT" + ) + + print( # noqa: T201 + "warning: " + f"node '{node_identifier}' in document " + f"'{document_title}' has an unresolved inline link to " + f"'{inline_link_.link}'." + ) + @timing_decorator("Export SDoc") def export(self) -> None: assert self.traceability_index is not None, ( From 0f81ce45b45157f1786922715c811aa3997893b4 Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Mon, 6 Jul 2026 08:46:57 -0300 Subject: [PATCH 09/10] feat(traceability): improve handling of unresolved inline links in relaxed mode - Add unit tests to validate query methods correctly report unresolved inline links - Add unit tests to ensure resolved inline links are not flagged as unresolved --- strictdoc/core/traceability_index_builder.py | 18 +++-- .../input.sdoc | 28 ++++++++ .../test.itest | 24 +++++++ .../strictdoc/core/test_traceability_index.py | 69 +++++++++++++++++++ .../html/renderers/test_markup_renderer.py | 51 ++++++++++++++ 5 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/input.sdoc create mode 100644 tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/test.itest diff --git a/strictdoc/core/traceability_index_builder.py b/strictdoc/core/traceability_index_builder.py index 0c76ad3e7..437437297 100644 --- a/strictdoc/core/traceability_index_builder.py +++ b/strictdoc/core/traceability_index_builder.py @@ -269,16 +269,22 @@ def create( if isinstance(inline_link_field, SDocNodeField) else None ) - node_title = ( - inline_link_node.reserved_title - if isinstance(inline_link_node, SDocNode) - else None - ) + node_name: Optional[str] = None + if isinstance(inline_link_node, SDocNode): + node_name = ( + inline_link_node.reserved_title + if inline_link_node.reserved_title is not None + else inline_link_node.reserved_uid + ) traceability_index.validation_index.add_issue( inline_link_node, issue=f"Missing link: {inline_link.link}", field=field_name, - subject=f"Node: {node_title}", + subject=( + f"Node: {node_name}" + if node_name is not None + else None + ), ) continue raise StrictDocException( diff --git a/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/input.sdoc b/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/input.sdoc new file mode 100644 index 000000000..12f94c8e4 --- /dev/null +++ b/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/input.sdoc @@ -0,0 +1,28 @@ +[DOCUMENT] +TITLE: Doc with missing inline links + +[TEXT] +STATEMENT: >>> +Existing anchor definition. + +[ANCHOR: EXISTING-ANCHOR] +<<< + +[REQUIREMENT] +UID: REQ-001 +TITLE: Existing requirement +STATEMENT: Existing requirement statement. + +[REQUIREMENT] +UID: REQ-002 +TITLE: Requirement with a missing node link +STATEMENT: >>> +Valid node link [LINK: REQ-001] and missing node link [LINK: MISSING-UID]. +<<< + +[REQUIREMENT] +UID: REQ-003 +TITLE: Requirement with a missing anchor link +STATEMENT: >>> +Valid anchor link [LINK: EXISTING-ANCHOR] and missing anchor link [LINK: MISSING-ANCHOR]. +<<< diff --git a/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/test.itest b/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/test.itest new file mode 100644 index 000000000..b179fdcc8 --- /dev/null +++ b/tests/integration/features/sdoc/graph_consistency/09_inline_link_does_not_exist_relaxed_mode/test.itest @@ -0,0 +1,24 @@ +# +# In relaxed mode (--allow-missing-relation-requirements), an inline [LINK:] +# that references a missing node UID or a missing [ANCHOR:] produces a warning +# and a UI highlight instead of a hard error. Valid links still resolve. +# + +RUN: %strictdoc export %S --output-dir %T --allow-missing-relation-requirements | filecheck %s --dump-input=fail +CHECK: warning: Missing link: MISSING-UID +CHECK: warning: Missing link: MISSING-ANCHOR +CHECK: warning: unresolved inline links were detected and allowed by configuration. +CHECK: warning: unresolved inline links count: 2 +CHECK: warning: node 'REQ-002' in document 'Doc with missing inline links' has an unresolved inline link to 'MISSING-UID'. +CHECK: warning: node 'REQ-003' in document 'Doc with missing inline links' has an unresolved inline link to 'MISSING-ANCHOR'. +CHECK: Published: Doc with missing inline links + +RUN: %cat %T/html/%THIS_TEST_FOLDER/input.html | filecheck %s --check-prefix CHECK-HTML + +# The missing node link is rendered as a dead link and highlighted as an issue. +CHECK-HTML: MISSING-UID +CHECK-HTML: Missing link: MISSING-UID + +# The missing anchor link is rendered as a dead link and highlighted as an issue. +CHECK-HTML: MISSING-ANCHOR +CHECK-HTML: Missing link: MISSING-ANCHOR diff --git a/tests/unit/strictdoc/core/test_traceability_index.py b/tests/unit/strictdoc/core/test_traceability_index.py index 25510673d..f5cc15428 100644 --- a/tests/unit/strictdoc/core/test_traceability_index.py +++ b/tests/unit/strictdoc/core/test_traceability_index.py @@ -4,6 +4,7 @@ import pytest +from strictdoc.backend.sdoc.models.inline_link import InlineLink from strictdoc.backend.sdoc.models.reference import ( ChildReqReference, FileReference, @@ -522,3 +523,71 @@ def test__missing_parent_relation__strict_mode_raises(): assert "references parent requirement which doesn't exist" in str( exception_info.value ) + + +def test_unresolved_inline_link_is_reported_by_query_methods(): + document_builder = DocumentBuilder() + requirement = document_builder.add_requirement("REQ-001") + + statement_field = requirement.ordered_fields_lookup["STATEMENT"][0] + missing_inline_link = InlineLink( + parent=statement_field, value="MISSING-UID" + ) + statement_field.parts.append(missing_inline_link) + + document = document_builder.build() + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + traceability_index = TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert traceability_index.has_unresolved_inline_links() is True + + unresolved_inline_links = ( + traceability_index.get_all_unresolved_inline_links() + ) + assert len(unresolved_inline_links) == 1 + + node_, inline_link_ = unresolved_inline_links[0] + assert node_ is requirement + assert inline_link_.link == "MISSING-UID" + + assert traceability_index.get_unresolved_inline_links(requirement) == [ + missing_inline_link + ] + + +def test_resolved_inline_link_is_not_reported_as_unresolved(): + document_builder = DocumentBuilder() + requirement = document_builder.add_requirement("REQ-001") + # The target of the inline link exists in the same document. + document_builder.add_requirement("REQ-002") + + statement_field = requirement.ordered_fields_lookup["STATEMENT"][0] + statement_field.parts.append( + InlineLink(parent=statement_field, value="REQ-002") + ) + + document = document_builder.build() + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + traceability_index = TraceabilityIndexBuilder.create_from_document_tree( + document_tree, project_config=document_builder.project_config + ) + + assert traceability_index.has_unresolved_inline_links() is False + assert traceability_index.get_all_unresolved_inline_links() == [] + assert ( + traceability_index.get_unresolved_inline_links(requirement) == [] + ) diff --git a/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py b/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py index 3dec5a30b..85b106194 100644 --- a/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py +++ b/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py @@ -2,8 +2,10 @@ MarkdownToHtmlFragmentWriter, ) from strictdoc.backend.sdoc.constants import SDocMarkup +from strictdoc.backend.sdoc.models.inline_link import InlineLink from strictdoc.core.document_tree import DocumentTree from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder +from strictdoc.export.html.document_type import DocumentType from strictdoc.export.html.html_templates import HTMLTemplates from strictdoc.export.html.renderers.link_renderer import LinkRenderer from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer @@ -46,3 +48,52 @@ def test_01_uses_markdown_fragment_writer_for_markdown_markup(): assert isinstance( markup_renderer.fragment_writer, MarkdownToHtmlFragmentWriter ) + + +def test_02_renders_unresolved_inline_link_as_dead_link(): + document_builder = DocumentBuilder() + document = document_builder.build() + document.config.markup = SDocMarkup.MARKDOWN + requirement = document_builder.add_requirement("REQ-001") + + statement_field = requirement.ordered_fields_lookup["STATEMENT"][0] + statement_field.parts.append( + InlineLink(parent=statement_field, value="MISSING-UID") + ) + + document_tree = DocumentTree( + file_tree=[], + document_list=[document], + map_docs_by_paths={}, + map_docs_by_rel_paths={}, + map_grammars_by_filenames={}, + ) + traceability_index = TraceabilityIndexBuilder.create_from_document_tree( + document_tree, + project_config=document_builder.project_config, + ) + + html_templates = HTMLTemplates.create( + project_config=document_builder.project_config, + enable_caching=False, + strictdoc_last_update=traceability_index.strictdoc_last_update, + ) + link_renderer = LinkRenderer(root_path="", static_path="_static") + + markup_renderer = MarkupRenderer.create( + markup=document.config.get_markup(), + traceability_index=traceability_index, + link_renderer=link_renderer, + html_templates=html_templates, + config=document_builder.project_config, + context_document=document, + ) + + # An unresolved inline link must render as a dead link ("#") instead of + # raising an exception, so that the relaxed mode can generate the document. + output = markup_renderer.render_node_field( + DocumentType.DOCUMENT, statement_field + ) + + assert "MISSING-UID" in output + assert 'href="#"' in output From de7bf1503cf7204ff396c522ef5d205f2c7a7bfe Mon Sep 17 00:00:00 2001 From: Claudio Lima Date: Tue, 7 Jul 2026 08:41:38 -0300 Subject: [PATCH 10/10] refactor: format code using ruff --- strictdoc/backend/rst/writer.py | 4 +- strictdoc/commands/export_config.py | 4 +- strictdoc/commands/server_config.py | 4 +- strictdoc/core/project_config.py | 6 +-- strictdoc/core/query_engine/query_object.py | 4 +- strictdoc/core/traceability_index.py | 38 +++++++++++++------ .../export/html/renderers/markup_renderer.py | 4 +- strictdoc/features/export/export_action.py | 2 +- .../core/query_engine/test_query_reader.py | 2 +- .../strictdoc/core/test_traceability_index.py | 11 +----- 10 files changed, 49 insertions(+), 30 deletions(-) diff --git a/strictdoc/backend/rst/writer.py b/strictdoc/backend/rst/writer.py index 95fe077be..087aaf44e 100644 --- a/strictdoc/backend/rst/writer.py +++ b/strictdoc/backend/rst/writer.py @@ -127,7 +127,9 @@ def _print_node_field(self, object_with_parts: SDocNodeField) -> str: else: output += part elif isinstance(part, InlineLink): - node_or_none = self.index.get_linkable_node_by_uid_weak(part.link) + node_or_none = self.index.get_linkable_node_by_uid_weak( + part.link + ) # Labels that aren't placed before a section title can still be # referenced, but you must give the link an explicit title, # using this syntax: :ref:`Link title `. diff --git a/strictdoc/commands/export_config.py b/strictdoc/commands/export_config.py index 3946fe3e7..136c922a2 100644 --- a/strictdoc/commands/export_config.py +++ b/strictdoc/commands/export_config.py @@ -53,7 +53,9 @@ def __init__( self.generate_diff_git: Optional[str] = generate_diff_git self.generate_diff_dirs: Optional[Tuple[str, str]] = generate_diff_dirs self.chromedriver: Optional[str] = chromedriver - self.allow_missing_relation_requirements: bool = allow_missing_relation_requirements + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + ) def get_path_to_config(self) -> str: # FIXME: The control flow can be improved. diff --git a/strictdoc/commands/server_config.py b/strictdoc/commands/server_config.py index 6ed36d135..af52bb66d 100644 --- a/strictdoc/commands/server_config.py +++ b/strictdoc/commands/server_config.py @@ -31,7 +31,9 @@ def __init__( self.host: Optional[str] = host self.port: Optional[int] = port self.watch: bool = watch - self.allow_missing_relation_requirements: bool = allow_missing_relation_requirements + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + ) def get_full_input_path(self) -> str: return os.path.abspath(self._input_path) diff --git a/strictdoc/core/project_config.py b/strictdoc/core/project_config.py index 291cd5787..6a0e5af4d 100644 --- a/strictdoc/core/project_config.py +++ b/strictdoc/core/project_config.py @@ -395,9 +395,9 @@ def __init__( ) self.document_line_width: Optional[int] = document_line_width - assert isinstance( - allow_missing_relation_requirements, bool - ), allow_missing_relation_requirements + assert isinstance(allow_missing_relation_requirements, bool), ( + allow_missing_relation_requirements + ) self.allow_missing_relation_requirements: bool = ( allow_missing_relation_requirements ) diff --git a/strictdoc/core/query_engine/query_object.py b/strictdoc/core/query_engine/query_object.py index 0446486b1..042e5ad2c 100644 --- a/strictdoc/core/query_engine/query_object.py +++ b/strictdoc/core/query_engine/query_object.py @@ -328,7 +328,9 @@ def _evaluate_node_has_missing_relations( f"Requirement objects, got: {node.__class__.__name__}. To fix " f"the error, prepend your query with node.is_requirement." ) - return self.traceability_index.has_missing_relations_for_requirement(node) + return self.traceability_index.has_missing_relations_for_requirement( + node + ) def _evaluate_node_contains( self, diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index 50beee3cf..d67a87830 100644 --- a/strictdoc/core/traceability_index.py +++ b/strictdoc/core/traceability_index.py @@ -4,7 +4,7 @@ import datetime from copy import copy, deepcopy -from typing import Any, Dict, Generator, List, Optional, Tuple, Union, TypeGuard +from typing import Any, Dict, Generator, List, Optional, Tuple, TypeGuard, Union from strictdoc.backend.sdoc.document_reference import DocumentReference from strictdoc.backend.sdoc.models.anchor import Anchor @@ -309,10 +309,13 @@ def get_missing_relations( ] = [] for relation_ in requirement.relations: if isinstance(relation_, (ParentReqReference, ChildReqReference)): - if self.graph_database.get_link_value_weak( - link_type=GraphLinkType.UID_TO_NODE, - lhs_node=relation_.ref_uid, - ) is None: + if ( + self.graph_database.get_link_value_weak( + link_type=GraphLinkType.UID_TO_NODE, + lhs_node=relation_.ref_uid, + ) + is None + ): missing_relations.append(relation_) continue @@ -388,9 +391,7 @@ def get_all_missing_relations( def has_missing_relations(self) -> bool: return len(self.get_all_missing_relations()) > 0 - def get_unresolved_inline_links( - self, node: SDocNode - ) -> List[InlineLink]: + def get_unresolved_inline_links(self, node: SDocNode) -> List[InlineLink]: assert isinstance(node, SDocNode) unresolved_inline_links: List[InlineLink] = [] @@ -430,8 +431,13 @@ def has_unresolved_inline_links(self) -> bool: def get_missing_parent_relations( self, requirement: SDocNode ) -> List[ParentReqReference]: - def is_parent_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[ParentReqReference]: + def is_parent_relation( + relation: Union[ + ParentReqReference, ChildReqReference, FileReference + ], + ) -> TypeGuard[ParentReqReference]: return isinstance(relation, ParentReqReference) + return list( filter( is_parent_relation, @@ -442,8 +448,13 @@ def is_parent_relation(relation: Union[ParentReqReference, ChildReqReference, Fi def get_missing_child_relations( self, requirement: SDocNode ) -> List[ChildReqReference]: - def is_child_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[ChildReqReference]: + def is_child_relation( + relation: Union[ + ParentReqReference, ChildReqReference, FileReference + ], + ) -> TypeGuard[ChildReqReference]: return isinstance(relation, ChildReqReference) + return list( filter( is_child_relation, @@ -454,8 +465,13 @@ def is_child_relation(relation: Union[ParentReqReference, ChildReqReference, Fil def get_missing_file_relations( self, requirement: SDocNode ) -> List[FileReference]: - def is_file_relation(relation: Union[ParentReqReference, ChildReqReference, FileReference]) -> TypeGuard[FileReference]: + def is_file_relation( + relation: Union[ + ParentReqReference, ChildReqReference, FileReference + ], + ) -> TypeGuard[FileReference]: return isinstance(relation, FileReference) + return list( filter( is_file_relation, diff --git a/strictdoc/export/html/renderers/markup_renderer.py b/strictdoc/export/html/renderers/markup_renderer.py index 40b1288bd..dd0dab21b 100644 --- a/strictdoc/export/html/renderers/markup_renderer.py +++ b/strictdoc/export/html/renderers/markup_renderer.py @@ -198,7 +198,9 @@ def render_node_field( parts_output += part elif isinstance(part, InlineLink): linkable_node = ( - self.traceability_index.get_linkable_node_by_uid_weak(part.link) + self.traceability_index.get_linkable_node_by_uid_weak( + part.link + ) ) if linkable_node is not None: href = self.link_renderer.render_node_link( diff --git a/strictdoc/features/export/export_action.py b/strictdoc/features/export/export_action.py index 5ce3c15fd..2d9406193 100644 --- a/strictdoc/features/export/export_action.py +++ b/strictdoc/features/export/export_action.py @@ -69,7 +69,7 @@ def _print_missing_relations_warning(self) -> None: "detected and allowed by configuration." ) print( # noqa: T201 - f"warning: missing relations count: " f"{len(missing_relations)}" + f"warning: missing relations count: {len(missing_relations)}" ) for requirement_, relation_ in missing_relations: diff --git a/tests/unit/strictdoc/core/query_engine/test_query_reader.py b/tests/unit/strictdoc/core/query_engine/test_query_reader.py index c70f2eb2f..b772f5171 100644 --- a/tests/unit/strictdoc/core/query_engine/test_query_reader.py +++ b/tests/unit/strictdoc/core/query_engine/test_query_reader.py @@ -2,8 +2,8 @@ EqualExpression, InExpression, NodeContainsAnyFreeTextExpression, - NodeHasMissingRelationsExpression, NodeFieldExpression, + NodeHasMissingRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsSectionExpression, diff --git a/tests/unit/strictdoc/core/test_traceability_index.py b/tests/unit/strictdoc/core/test_traceability_index.py index f5cc15428..e6b5db143 100644 --- a/tests/unit/strictdoc/core/test_traceability_index.py +++ b/tests/unit/strictdoc/core/test_traceability_index.py @@ -5,18 +5,12 @@ import pytest from strictdoc.backend.sdoc.models.inline_link import InlineLink -from strictdoc.backend.sdoc.models.reference import ( - ChildReqReference, - FileReference, - ParentReqReference, -) from strictdoc.core.document_tree import DocumentTree from strictdoc.core.traceability_index import TraceabilityIndex from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder from strictdoc.helpers.exception import StrictDocException from strictdoc.helpers.mid import MID from tests.unit.helpers.document_builder import DocumentBuilder -from typing import List def test_valid_01_one_document_with_1req(): @@ -495,6 +489,7 @@ def test__delete_requirement__parent_child_links_cleaned_up_symmetrically(): # gone; previously it was left in place. assert traceability_index.get_children_requirements(requirement1) == [] + def test__missing_parent_relation__strict_mode_raises(): document_builder = DocumentBuilder() document_builder.add_requirement("REQ-001") @@ -588,6 +583,4 @@ def test_resolved_inline_link_is_not_reported_as_unresolved(): assert traceability_index.has_unresolved_inline_links() is False assert traceability_index.get_all_unresolved_inline_links() == [] - assert ( - traceability_index.get_unresolved_inline_links(requirement) == [] - ) + assert traceability_index.get_unresolved_inline_links(requirement) == []