diff --git a/docs/sdoc_project_statistics.py b/docs/sdoc_project_statistics.py index 4dd36c206..b858231db 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,16 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) + 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/backend/rst/writer.py b/strictdoc/backend/rst/writer.py index 6738413ec..087aaf44e 100644 --- a/strictdoc/backend/rst/writer.py +++ b/strictdoc/backend/rst/writer.py @@ -127,15 +127,23 @@ 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/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..136c922a2 100644 --- a/strictdoc/commands/export_config.py +++ b/strictdoc/commands/export_config.py @@ -30,6 +30,7 @@ def __init__( generate_diff_git: Optional[str], generate_diff_dirs: Optional[Tuple[str, str]], chromedriver: Optional[str], + allow_missing_relation_requirements: bool = False, ): assert isinstance(input_paths, list), f"{input_paths}" self.debug: bool = debug @@ -52,6 +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 + ) 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 f06e4c563..3ff527c11 100644 --- a/strictdoc/commands/server.py +++ b/strictdoc/commands/server.py @@ -45,6 +45,16 @@ def add_arguments(cls, parser: argparse.ArgumentParser) -> None: "documents change on disk." ), ) + command_parser_server.add_argument( + "--allow-missing-relation-requirements", + default=False, + action="store_true", + dest="allow_missing_relation_requirements", + 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 adcb3caa1..af52bb66d 100644 --- a/strictdoc/commands/server_config.py +++ b/strictdoc/commands/server_config.py @@ -20,6 +20,7 @@ def __init__( host: Optional[str], port: Optional[int], watch: bool = False, + allow_missing_relation_requirements: bool = False, ): self.debug: bool = debug self.command: str = command @@ -30,6 +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 + ) def get_full_input_path(self) -> str: return os.path.abspath(self._input_path) diff --git a/strictdoc/core/file_traceability_index.py b/strictdoc/core/file_traceability_index.py index c26da36cb..007f83de5 100644 --- a/strictdoc/core/file_traceability_index.py +++ b/strictdoc/core/file_traceability_index.py @@ -612,6 +612,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/project_config.py b/strictdoc/core/project_config.py index 539ff7774..6a0e5af4d 100644 --- a/strictdoc/core/project_config.py +++ b/strictdoc/core/project_config.py @@ -153,6 +153,7 @@ def __init__( ] = ProjectConfigDefault.DEFAULT_SECTION_BEHAVIOR, statistics_generator: Optional[str] = None, document_line_width: Optional[int] = None, + allow_missing_relation_requirements: bool = False, # 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 +395,13 @@ def __init__( ) self.document_line_width: Optional[int] = document_line_width + assert isinstance(allow_missing_relation_requirements, bool), ( + allow_missing_relation_requirements + ) + self.allow_missing_relation_requirements: bool = ( + allow_missing_relation_requirements + ) + self.user_plugin: Optional[StrictDocPlugin] = user_plugin # Optional launcher logo path (absolute or workspace-relative). @@ -452,6 +460,9 @@ 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 + def integrate_export_config( self, export_config: ExportCommandConfig ) -> None: @@ -520,6 +531,9 @@ 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 + def validate_and_finalize(self) -> None: project_path = self.get_project_root_path() @@ -1016,6 +1030,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"] @@ -1111,6 +1126,11 @@ def _load_from_dictionary( "document_line_width", document_line_width ) + allow_missing_relation_requirements = project_content.get( + "allow_missing_relation_requirements", + allow_missing_relation_requirements, + ) + if "source_nodes" in project_content: source_nodes_config = project_content["source_nodes"] assert isinstance(source_nodes_config, list) @@ -1168,5 +1188,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..f84698541 100644 --- a/strictdoc/core/query_engine/grammar.py +++ b/strictdoc/core/query_engine/grammar.py @@ -37,6 +37,8 @@ | NodeHasChildRequirementsExpression | + NodeHasMissingRelationsExpression + | InExpression | NotInExpression @@ -92,6 +94,10 @@ _ = 'node.has_child_requirements' ; +NodeHasMissingRelationsExpression: + _ = 'node.has_missing_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..042e5ad2c 100644 --- a/strictdoc/core/query_engine/query_object.py +++ b/strictdoc/core/query_engine/query_object.py @@ -62,6 +62,11 @@ 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 +181,8 @@ 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, NodeIsRequirementExpression): return ( isinstance(node, SDocNode) and node.node_type == "REQUIREMENT" @@ -312,6 +319,19 @@ 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..523e5a66e 100644 --- a/strictdoc/core/query_engine/query_reader.py +++ b/strictdoc/core/query_engine/query_reader.py @@ -15,6 +15,7 @@ NodeContainsExpression, NodeFieldExpression, NodeHasChildRequirementsExpression, + NodeHasMissingRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, @@ -40,6 +41,7 @@ NodeContainsAnyFreeTextExpression, NodeFieldExpression, NodeHasChildRequirementsExpression, + NodeHasMissingRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsRootExpression, diff --git a/strictdoc/core/traceability_index.py b/strictdoc/core/traceability_index.py index 6815894f0..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 +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 @@ -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 @@ -298,6 +299,186 @@ def get_parent_requirements(self, requirement: SDocNode) -> List[SDocNode]: ) ) + def get_missing_relations( + self, requirement: SDocNode + ) -> List[Union[ParentReqReference, ChildReqReference, FileReference]]: + assert isinstance(requirement, SDocNode) + + missing_relations: List[ + Union[ParentReqReference, ChildReqReference, FileReference] + ] = [] + 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 + ): + missing_relations.append(relation_) + continue + + 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 + + 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, FileReference], + ] + ]: + assert isinstance(document, SDocDocument) + + missing_relations: List[ + Tuple[ + SDocNode, + Union[ + ParentReqReference, + ChildReqReference, + FileReference, + ], + ] + ] = [] + 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, FileReference], + ] + ]: + missing_relations: List[ + Tuple[ + SDocNode, + Union[ + ParentReqReference, + ChildReqReference, + FileReference, + ], + ] + ] = [] + 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 + + 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 + ) -> List[ParentReqReference]: + def is_parent_relation( + relation: Union[ + ParentReqReference, ChildReqReference, FileReference + ], + ) -> TypeGuard[ParentReqReference]: + return isinstance(relation, ParentReqReference) + + return list( + filter( + is_parent_relation, + self.get_missing_relations(requirement), + ) + ) + + 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( + is_file_relation, + self.get_missing_relations(requirement), + ) + ) + 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 8f9559786..9674fb9b4 100644 --- a/strictdoc/core/traceability_index_builder.py +++ b/strictdoc/core/traceability_index_builder.py @@ -28,7 +28,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, @@ -259,6 +259,36 @@ 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_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_name}" + if node_name is not None + else None + ), + ) + continue raise StrictDocException( "DocumentIndex: " "the inline link references an object with an UID " @@ -651,6 +681,14 @@ def create_from_document_tree( lhs_node=parent_reference.ref_uid, ) if parent_requirement is None: + 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] " f"Requirement {requirement.reserved_uid} " @@ -698,6 +736,14 @@ def create_from_document_tree( lhs_node=child_reference.ref_uid, ) if child_requirement is None: + 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] " f"Requirement {requirement.reserved_uid} " diff --git a/strictdoc/export/html/renderers/markup_renderer.py b/strictdoc/export/html/renderers/markup_renderer.py index 9a82857f9..dd0dab21b 100644 --- a/strictdoc/export/html/renderers/markup_renderer.py +++ b/strictdoc/export/html/renderers/markup_renderer.py @@ -198,14 +198,26 @@ 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 new file mode 100644 index 000000000..931dd13e9 --- /dev/null +++ b/strictdoc/export/html/templates/_shared/missing_relations_project_warning.jinja.html @@ -0,0 +1,9 @@ +{%- 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_tracebility }} relation(s) or link(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/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 c413c77be..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,6 +1,7 @@ {# needs sdoc_entity, see README.txt #} {%- set current_sdoc_entity = sdoc_entity -%} - {%- if view_object.traceability_index.has_parent_requirements(sdoc_entity) %} + {%- 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) %} + {%- 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/table/field_display_mode/relations.jinja b/strictdoc/export/html/templates/screens/document/table/field_display_mode/relations.jinja index 7f9d6dfef..0898ace75 100644 --- a/strictdoc/export/html/templates/screens/document/table/field_display_mode/relations.jinja +++ b/strictdoc/export/html/templates/screens/document/table/field_display_mode/relations.jinja @@ -16,6 +16,22 @@ {%- endfor %} {%- endif %} +{%- set missing_parent_relations = view_object.traceability_index.get_missing_parent_relations(requirement) %} +{%- if missing_parent_relations | length > 0 %} + Missing Parents: + +{%- endif %} {%- if view_object.traceability_index.has_children_requirements(requirement) %} Children: {%- endif %} +{%- set missing_child_relations = view_object.traceability_index.get_missing_child_relations(requirement) %} +{%- 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 %} @@ -51,4 +83,20 @@ {%- endfor -%} {%- 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 %} diff --git a/strictdoc/features/export/export_action.py b/strictdoc/features/export/export_action.py index 5c36074ab..2d9406193 100644 --- a/strictdoc/features/export/export_action.py +++ b/strictdoc/features/export/export_action.py @@ -10,6 +10,7 @@ from strictdoc.backend.rst.document_rst_generator import DocumentRSTGenerator from strictdoc.backend.sdoc.errors.document_tree_error import DocumentTreeError from strictdoc.backend.sdoc.models.document import SDocDocument +from strictdoc.backend.sdoc.models.reference import FileReference from strictdoc.backend.sdoc.writer import SDWriter from strictdoc.backend.spdx.spdx_generator import SPDXGenerator from strictdoc.core.project_config import ProjectConfig @@ -51,8 +52,95 @@ 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() + self._print_unresolved_inline_links_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 relations were " + "detected and allowed by configuration." + ) + print( # noqa: T201 + f"warning: missing relations count: {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 "" + ) + + if isinstance(relation_, FileReference): + relation_target = f"file '{relation_.get_posix_path()}'" + else: + relation_target = f"requirement '{relation_.ref_uid}'" + + print( # noqa: T201 + "warning: " + f"requirement '{requirement_identifier}' in document " + f"'{document_title}' has a missing relation of type " + 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, ( diff --git a/strictdoc/features/project_index/templates/features/project_index/main.jinja b/strictdoc/features/project_index/templates/features/project_index/main.jinja index bef845b42..75286c1dc 100644 --- a/strictdoc/features/project_index/templates/features/project_index/main.jinja +++ b/strictdoc/features/project_index/templates/features/project_index/main.jinja @@ -1,4 +1,5 @@
+ {% include "_shared/missing_relations_project_warning.jinja.html" %}
{% include "features/project_index/frame_project_tree.jinja.html" %} diff --git a/strictdoc/features/project_statistics/generator.py b/strictdoc/features/project_statistics/generator.py index b925ab2ad..9b79ce461 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,16 @@ def export( link='search?q=(node.is_requirement() and not node.is_root and node["STATUS"] != "Backlog" and not node.has_parent_requirements)', ) ) + 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/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/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 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/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/query_engine/test_query_reader.py b/tests/unit/strictdoc/core/query_engine/test_query_reader.py index ca1386817..b772f5171 100644 --- a/tests/unit/strictdoc/core/query_engine/test_query_reader.py +++ b/tests/unit/strictdoc/core/query_engine/test_query_reader.py @@ -3,6 +3,7 @@ InExpression, NodeContainsAnyFreeTextExpression, NodeFieldExpression, + NodeHasMissingRelationsExpression, NodeHasParentRequirementsExpression, NodeIsRequirementExpression, NodeIsSectionExpression, @@ -131,6 +132,18 @@ def test_62_node_is_requirement(): assert isinstance(query_object.root_expression, NodeIsRequirementExpression) +def test_62_1_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 76196210b..e6b5db143 100644 --- a/tests/unit/strictdoc/core/test_traceability_index.py +++ b/tests/unit/strictdoc/core/test_traceability_index.py @@ -2,9 +2,13 @@ @relation(SDOC-SRS-28, scope=file) """ +import pytest + +from strictdoc.backend.sdoc.models.inline_link import InlineLink 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 @@ -484,3 +488,99 @@ 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) == [] + + +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_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 976342714..5eec2d7ec 100644 --- a/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py +++ b/tests/unit/strictdoc/export/html/renderers/test_markup_renderer.py @@ -3,6 +3,7 @@ ) from strictdoc.backend.sdoc.constants import SDocMarkup from strictdoc.backend.sdoc.models.anchor import Anchor +from strictdoc.backend.sdoc.models.inline_link import InlineLink from strictdoc.backend.sdoc.models.node import SDocNodeField from strictdoc.core.document_tree import DocumentTree from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder @@ -97,3 +98,52 @@ def test_02_anchor_render_starts_new_block(): output = markup_renderer.render_node_field(DocumentType.DOCUMENT, field) assert "

Text after." in output + + +def test_03_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