StrictDoc Documentation
strictdoc/core/traceability_index.py
Source file coverage
Path:
strictdoc/core/traceability_index.py
Lines:
1321
Non-empty lines:
1159
Non-empty lines covered with requirements:
1159 / 1159 (100.0%)
Functions:
64
Functions covered by requirements:
64 / 64 (100.0%)
1
"""
2
@relation(SDOC-SRS-28, scope=file)
3
"""
4
 
5
import datetime
6
from copy import copy, deepcopy
7
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
8
 
9
from strictdoc.backend.sdoc.document_reference import DocumentReference
10
from strictdoc.backend.sdoc.models.anchor import Anchor
11
from strictdoc.backend.sdoc.models.document import SDocDocument
12
from strictdoc.backend.sdoc.models.document_config import DocumentConfig
13
from strictdoc.backend.sdoc.models.grammar_element import GrammarElement
14
from strictdoc.backend.sdoc.models.inline_link import InlineLink
15
from strictdoc.backend.sdoc.models.node import SDocNode
16
from strictdoc.backend.sdoc.models.reference import (
17
    ChildReqReference,
18
    ParentReqReference,
19
)
20
from strictdoc.backend.sdoc.node_filter import NodeFilter
21
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
22
    RelationMarkerType,
23
    SourceFileTraceabilityInfo,
24
)
25
from strictdoc.core.asset_manager import AssetManager
26
from strictdoc.core.constants import GraphLinkType
27
from strictdoc.core.document_iterator import SDocDocumentIterator
28
from strictdoc.core.document_meta import DocumentMeta
29
from strictdoc.core.document_tree import DocumentTree
30
from strictdoc.core.file_dependency_manager import FileDependencyManager
31
from strictdoc.core.file_system.source_tree import SourceFile
32
from strictdoc.core.file_traceability_index import FileTraceabilityIndex
33
from strictdoc.core.graph.abstract_bucket import ALL_EDGES
34
from strictdoc.core.graph_database import GraphDatabase
35
from strictdoc.core.project_config import ProjectConfig
36
from strictdoc.core.transforms.constants import NodeCreationOrder
37
from strictdoc.core.transforms.validation_error import (
38
    MultipleValidationErrorAsList,
39
    SingleValidationError,
40
)
41
from strictdoc.core.tree_cycle_detector import TreeCycleDetector
42
from strictdoc.core.validation_index import ValidationIndex
43
from strictdoc.helpers.cast import assert_cast, assert_optional_cast
44
from strictdoc.helpers.file_modification_time import set_file_modification_time
45
from strictdoc.helpers.mid import MID
46
from strictdoc.helpers.ordered_set import OrderedSet
47
from strictdoc.helpers.paths import SDocRelativePath
48
from strictdoc.helpers.sorting import alphanumeric_sort
49
 
50
 
51
class TraceabilityIndex:
52
    def __init__(
53
        self,
54
        document_tree: DocumentTree,
55
        document_iterators: Dict[SDocDocument, SDocDocumentIterator],
56
        file_traceability_index: FileTraceabilityIndex,
57
        graph_database: GraphDatabase,
58
        file_dependency_manager: FileDependencyManager,
59
    ):
60
        self._document_iterators: Dict[SDocDocument, SDocDocumentIterator] = (
61
            document_iterators
62
        )
63
        self._file_traceability_index: FileTraceabilityIndex = (
64
            file_traceability_index
65
        )
66
        self.validation_index: ValidationIndex = ValidationIndex()
67
        self.graph_database: GraphDatabase = graph_database
68
        self.document_tree: DocumentTree = document_tree
69
        self.asset_manager: Optional[AssetManager] = None
70
        self.file_dependency_manager: FileDependencyManager = (
71
            file_dependency_manager
72
        )
73
        self.node_filter: Optional[NodeFilter] = None
74
 
75
        self.index_last_updated = datetime.datetime.today()
76
        self.contains_included_documents = False
77
        self.strictdoc_last_update: datetime.datetime = (
78
            datetime.datetime.fromtimestamp(0)
79
        )
80
 
81
        # The timestamp is used by HTML/JS for invalidating the search index
82
        # cache in the IndexedDB database.
83
        # If no documents have to be re-generated with the second+ run of
84
        # StrictDoc, this timestamp is set of a modification date of the first
85
        # SDoc document, see export_static_html_search_index.
86
        self.search_index_timestamp: datetime.datetime = datetime.datetime.now(
87
            datetime.timezone.utc
88
        )
89
 
90
    @property
91
    def document_iterators(self) -> Dict[SDocDocument, SDocDocumentIterator]:
92
        return self._document_iterators
93
 
94
    def is_small_project(self) -> bool:
95
        """
96
        Check if project is small to control feature availability.
97
 
98
        This method helps to decide if StrictDoc will precompile Jinja templates
99
        to Python files or not. Precompilation may take half a second time, so
100
        it is only worth doing it when a project is relatively large.
101
 
102
        Below, making some assumptions about what makes a small or larger
103
        project.
104
        """
105
        if len(self.document_tree.document_list) >= 3:
106
            return False
107
        for document_ in self.document_tree.document_list:
108
            if len(document_.section_contents) > 5:
109
                return False
110
        return (
111
            self.graph_database.get_count(
112
                link_type=GraphLinkType.NODE_TO_PARENT_NODES
113
            )
114
            <= 10
115
        )
116
 
117
    def can_edit_document(self, document: SDocDocument) -> bool:
118
        assert isinstance(document, SDocDocument), document
119
        return not document.autogen
120
 
121
    def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
122
        assert isinstance(node, (SDocDocument, SDocNode)), node
123
        if isinstance(node, SDocDocument):
124
            return not node.autogen
125
 
126
        if node.get_parent_or_including_document().autogen or node.autogen:
127
            return False
128
 
129
        return True
130
 
131
    def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
132
        assert isinstance(node, (SDocDocument, SDocNode)), node
133
        if isinstance(node, SDocDocument):
134
            return not node.autogen
135
 
136
        if node.get_parent_or_including_document().autogen:
137
            return False
138
 
139
        return not node.is_managed_by_source_code()
140
 
141
    def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
142
        assert isinstance(node, (SDocDocument, SDocNode)), node
143
        if isinstance(node, SDocDocument):
144
            return False
145
 
146
        return self.can_delete_node(node)
147
 
148
    def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
149
        assert isinstance(node, (SDocDocument, SDocNode)), node
150
        if isinstance(node, SDocDocument):
151
            return not node.autogen
152
 
153
        if node.get_parent_or_including_document().autogen or node.autogen:
154
            return False
155
 
156
        return True
157
 
158
    def can_insert_next_to_node(
159
        self, node: Union[SDocDocument, SDocNode]
160
    ) -> bool:
161
        assert isinstance(node, (SDocDocument, SDocNode)), node
162
        if isinstance(node, SDocDocument):
163
            return True
164
 
165
        if node.get_parent_or_including_document().autogen:
166
            return False
167
 
168
        parent = node.parent
169
        if isinstance(parent, SDocNode) and parent.autogen:
170
            return False
171
 
172
        return True
173
 
174
    def can_create_node_at(
175
        self, reference_node: Union[SDocDocument, SDocNode], whereto: str
176
    ) -> bool:
177
        assert isinstance(reference_node, (SDocDocument, SDocNode)), (
178
            reference_node
179
        )
180
        assert isinstance(whereto, str), whereto
181
 
182
        if whereto == NodeCreationOrder.CHILD:
183
            return self.can_add_node(reference_node)
184
        if whereto in (NodeCreationOrder.BEFORE, NodeCreationOrder.AFTER):
185
            return self.can_insert_next_to_node(reference_node)
186
        return False
187
 
188
    def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
189
        assert isinstance(node, (SDocDocument, SDocNode)), node
190
        if isinstance(node, SDocDocument):
191
            return not node.autogen
192
 
193
        if node.get_parent_or_including_document().autogen or node.autogen:
194
            return False
195
 
196
        return True
197
 
198
    def can_move_node_to(
199
        self,
200
        moved_node: SDocNode,
201
        target_node: Union[SDocDocument, SDocNode],
202
        whereto: str,
203
    ) -> bool:
204
        assert isinstance(moved_node, SDocNode), moved_node
205
        assert isinstance(target_node, (SDocDocument, SDocNode)), target_node
206
        assert isinstance(whereto, str), whereto
207
 
208
        if not self.can_move_node(moved_node):
209
            return False
210
 
211
        if (
212
            whereto == NodeCreationOrder.CHILD
213
            and isinstance(target_node, SDocNode)
214
            and not target_node.is_composite
215
        ):
216
            return self.can_insert_next_to_node(target_node)
217
 
218
        return self.can_create_node_at(target_node, whereto)
219
 
220
    def has_parent_requirements(self, requirement: SDocNode) -> bool:
221
        assert isinstance(requirement, SDocNode)
222
        if not isinstance(requirement.reserved_uid, str):
223
            return False
224
 
225
        parent_requirements = self.graph_database.get_link_values(
226
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
227
            lhs_node=requirement,
228
            edge=ALL_EDGES,
229
        )
230
        return len(parent_requirements) > 0
231
 
232
    def has_children_requirements(self, requirement: SDocNode) -> bool:
233
        assert isinstance(requirement, SDocNode)
234
        if not isinstance(requirement.reserved_uid, str):
235
            return False
236
 
237
        children_requirements = self.graph_database.get_link_values(
238
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
239
            lhs_node=requirement,
240
            edge=ALL_EDGES,
241
        )
242
        return len(children_requirements) > 0
243
 
244
    def has_source_file_reqs(self, source_file_rel_path: str) -> bool:
245
        return self._file_traceability_index.has_source_file_reqs(
246
            source_file_rel_path
247
        )
248
 
249
    def has_node_connections(self, node_uid: str) -> bool:
250
        assert isinstance(node_uid, str), node_uid
251
        return self.graph_database.has_any_link(
252
            link_type=GraphLinkType.UID_TO_NODE,
253
            lhs_node=node_uid,
254
        )
255
 
256
    def get_node_by_mid(self, node_mid: MID) -> Any:
257
        assert isinstance(node_mid, MID), node_mid
258
        return self.graph_database.get_link_value(
259
            link_type=GraphLinkType.MID_TO_NODE, lhs_node=node_mid
260
        )
261
 
262
    def get_node_by_mid_weak(self, node_mid: MID) -> Optional[Any]:
263
        assert isinstance(node_mid, MID), node_mid
264
        return self.graph_database.get_link_value_weak(
265
            link_type=GraphLinkType.MID_TO_NODE, lhs_node=node_mid
266
        )
267
 
268
    def get_file_traceability_index(self) -> FileTraceabilityIndex:
269
        return self._file_traceability_index
270
 
271
    def get_document_by_title(
272
        self,
273
        document_title: str,
274
    ) -> SDocDocument:
275
        for document_ in self.document_tree.document_list:
276
            if document_.reserved_title == document_title:
277
                return document_
278
        raise LookupError(f"Document not found: '{document_title}'")
279
 
280
    def get_document_iterator(
281
        self, document: SDocDocument
282
    ) -> SDocDocumentIterator:
283
        return SDocDocumentIterator(
284
            document=document, node_filter=self.node_filter
285
        )
286
 
287
    def get_parent_requirements(self, requirement: SDocNode) -> List[SDocNode]:
288
        assert isinstance(requirement, SDocNode)
289
        if not isinstance(requirement.reserved_uid, str):
290
            return []
291
 
292
        return list(
293
            self.graph_database.get_link_values(
294
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
295
                lhs_node=requirement,
296
                edge=ALL_EDGES,
297
            )
298
        )
299
 
300
    def get_parent_relations_with_roles(
301
        self, node: SDocNode
302
    ) -> List[Tuple[SDocNode, Optional[str]]]:
303
        assert isinstance(node, SDocNode)
304
 
305
        return list(
306
            self.graph_database.get_link_values_with_edges(
307
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
308
                lhs_node=node,
309
                edge=ALL_EDGES,
310
            )
311
        )
312
 
313
    def get_parent_relations_with_role(
314
        self, requirement: SDocNode, role: Optional[str]
315
    ) -> List[Tuple[SDocNode, Optional[str]]]:
316
        assert isinstance(requirement, SDocNode)
317
        if requirement.reserved_uid is None:
318
            return []
319
 
320
        return list(
321
            self.graph_database.get_link_values_with_edges(
322
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
323
                lhs_node=requirement,
324
                edge=role,
325
            )
326
        )
327
 
328
    def get_child_relations_with_roles(
329
        self, requirement: SDocNode
330
    ) -> List[Tuple[SDocNode, Optional[str]]]:
331
        assert isinstance(requirement, SDocNode)
332
 
333
        return list(
334
            self.graph_database.get_link_values_with_edges(
335
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
336
                lhs_node=requirement,
337
                edge=ALL_EDGES,
338
            )
339
        )
340
 
341
    def get_child_relations_with_role(
342
        self, requirement: SDocNode, role: Optional[str]
343
    ) -> List[Tuple[SDocNode, Optional[str]]]:
344
        assert isinstance(requirement, SDocNode)
345
        if requirement.reserved_uid is None:
346
            return []
347
 
348
        return list(
349
            self.graph_database.get_link_values_with_edges(
350
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
351
                lhs_node=requirement,
352
                edge=role,
353
            )
354
        )
355
 
356
    def get_children_requirements(
357
        self, requirement: SDocNode
358
    ) -> List[SDocNode]:
359
        assert isinstance(requirement, SDocNode)
360
        if not isinstance(requirement.reserved_uid, str):
361
            return []
362
 
363
        return list(
364
            self.graph_database.get_link_values(
365
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
366
                lhs_node=requirement,
367
                edge=ALL_EDGES,
368
            )
369
        )
370
 
371
    def has_tags(self, document: SDocDocument) -> bool:
372
        return self.graph_database.has_any_link(
373
            link_type=GraphLinkType.DOCUMENT_TO_TAGS,
374
            lhs_node=document.reserved_mid,
375
        )
376
 
377
    def get_counted_tags(
378
        self, document: SDocDocument
379
    ) -> Generator[Tuple[str, int], None, None]:
380
        document_tags_or_none = self.graph_database.get_link_value(
381
            link_type=GraphLinkType.DOCUMENT_TO_TAGS,
382
            lhs_node=document.reserved_mid,
383
        )
384
        document_tags: Dict[str, int] = assert_cast(document_tags_or_none, dict)
385
 
386
        tags = sorted(document_tags.keys(), key=alphanumeric_sort)
387
        for tag in tags:
388
            yield tag, document_tags[tag]
389
 
390
    def get_requirement_file_links(
391
        self, requirement: SDocNode
392
    ) -> List[Tuple[str, List[RelationMarkerType]]]:
393
        return self._file_traceability_index.get_requirement_file_links(
394
            requirement
395
        )
396
 
397
    def get_source_file_reqs(
398
        self, source_file_rel_path: str
399
    ) -> Optional[List[SDocNode]]:
400
        return self._file_traceability_index.get_source_file_reqs(
401
            source_file_rel_path
402
        )
403
 
404
    def get_coverage_info(
405
        self, source_file_rel_path: str
406
    ) -> SourceFileTraceabilityInfo:
407
        return self._file_traceability_index.get_coverage_info(
408
            source_file_rel_path
409
        )
410
 
411
    def get_coverage_info_weak(
412
        self, source_file_rel_path: str
413
    ) -> Optional[SourceFileTraceabilityInfo]:
414
        return self._file_traceability_index.get_coverage_info_weak(
415
            source_file_rel_path
416
        )
417
 
418
    def get_node_by_uid(self, uid: str) -> Any:
419
        assert isinstance(uid, str) and len(uid) > 0, uid
420
        return self.graph_database.get_link_value(
421
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
422
        )
423
 
424
    def get_node_by_uid_weak2(self, uid: str) -> Optional[Any]:
425
        """
426
        FIXME: This can likely replace _weak below with no problem.
427
        """
428
        assert isinstance(uid, str) and len(uid) > 0, uid
429
        return self.graph_database.get_link_value_weak(
430
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
431
        )
432
 
433
    def get_linkable_node_by_uid(
434
        self, uid: str
435
    ) -> Union[SDocDocument, SDocNode, Anchor]:
436
        return assert_cast(
437
            self.get_node_by_uid(uid),
438
            (SDocDocument, SDocNode, Anchor),
439
        )
440
 
441
    def get_node_by_uid_weak(
442
        self, uid: str
443
    ) -> Union[SDocDocument, SDocNode, None]:
444
        assert isinstance(uid, str), uid
445
        for document in self.document_tree.document_list:
446
            document_iterator = SDocDocumentIterator(document)
447
            for node, _ in document_iterator.all_content(print_fragments=False):
448
                if isinstance(node, SDocDocument):
449
                    if node.config.uid == uid:
450
                        return node
451
                elif isinstance(node, SDocNode):
452
                    if node.reserved_uid == uid:
453
                        return node
454
                else:
455
                    raise NotImplementedError
456
        return None
457
 
458
    def get_linkable_node_by_uid_weak(
459
        self, uid: str
460
    ) -> Union[SDocDocument, SDocNode, Anchor, None]:
461
        return assert_optional_cast(
462
            self.graph_database.get_link_value_weak(
463
                link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
464
            ),
465
            (SDocDocument, SDocNode, Anchor),
466
        )
467
 
468
    def get_incoming_links(
469
        self, node: Union[SDocDocument, SDocNode, Anchor]
470
    ) -> Optional[List[InlineLink]]:
471
        incoming_links = self.graph_database.get_link_values(
472
            link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
473
            lhs_node=node.reserved_mid,
474
        )
475
        if incoming_links is None or len(incoming_links) == 0:
476
            return None
477
        # FIXME: Should the graph database return OrderedSet or a copied list()?
478
        return list(incoming_links)
479
 
480
    def get_grammar_element(
481
        self, document_uid: str, node_type: str
482
    ) -> Optional[GrammarElement]:
483
        document = self.get_node_by_uid_weak2(document_uid)
484
        if isinstance(document, SDocDocument) and document.grammar is not None:
485
            return document.grammar.elements_by_type.get(node_type)
486
        return None
487
 
488
    def create_traceability_info(
489
        self,
490
        source_file: SourceFile,
491
        traceability_info: SourceFileTraceabilityInfo,
492
    ) -> None:
493
        self._file_traceability_index.create_traceability_info(
494
            source_file, traceability_info
495
        )
496
 
497
    def create_document(self, document: SDocDocument) -> None:
498
        assert isinstance(document, SDocDocument)
499
        if document.reserved_uid is not None:
500
            self.graph_database.create_link(
501
                link_type=GraphLinkType.UID_TO_NODE,
502
                lhs_node=document.reserved_uid,
503
                rhs_node=document,
504
            )
505
        self.graph_database.create_link(
506
            link_type=GraphLinkType.MID_TO_NODE,
507
            lhs_node=document.reserved_mid,
508
            rhs_node=document,
509
        )
510
 
511
    def create_inline_link(self, new_link: InlineLink) -> None:
512
        assert isinstance(new_link, InlineLink)
513
 
514
        # InlineLink points to a section, node or to anchor.
515
        assert self.graph_database.has_any_link(
516
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=new_link.link
517
        )
518
 
519
        node_or_anchor: Union[SDocDocument, SDocNode, Anchor] = assert_cast(
520
            self.graph_database.get_link_value(
521
                link_type=GraphLinkType.UID_TO_NODE,
522
                lhs_node=new_link.link,
523
            ),
524
            (SDocDocument, SDocNode, Anchor),
525
        )
526
        self.graph_database.create_link(
527
            link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
528
            lhs_node=node_or_anchor.reserved_mid,
529
            rhs_node=new_link,
530
        )
531
        self.graph_database.create_link(
532
            link_type=GraphLinkType.MID_TO_NODE,
533
            lhs_node=new_link.reserved_mid,
534
            rhs_node=new_link,
535
        )
536
 
537
    def update_last_updated(self) -> None:
538
        """
539
        Update the index's last updated date to the current time.
540
 
541
        This is a rather broad way of signalling that all documents of the index
542
        need to be re-generated when they are opened next time. Several UI
543
        actions use this method to ensure a complete re-generation of all
544
        documents.
545
        """
546
        self.index_last_updated = datetime.datetime.today()
547
 
548
    def create_requirement(self, requirement: SDocNode) -> None:
549
        assert isinstance(requirement, SDocNode), requirement
550
 
551
        self.graph_database.create_link(
552
            link_type=GraphLinkType.MID_TO_NODE,
553
            lhs_node=requirement.reserved_mid,
554
            rhs_node=requirement,
555
        )
556
        if requirement.reserved_uid is not None:
557
            self.graph_database.create_link(
558
                link_type=GraphLinkType.UID_TO_NODE,
559
                lhs_node=requirement.reserved_uid,
560
                rhs_node=requirement,
561
            )
562
 
563
    def update_requirement_uid(
564
        self, requirement: SDocNode, old_uid: Optional[str]
565
    ) -> None:
566
        if old_uid is None:
567
            if requirement.reserved_uid:
568
                self.graph_database.create_link(
569
                    link_type=GraphLinkType.UID_TO_NODE,
570
                    lhs_node=requirement.reserved_uid,
571
                    rhs_node=requirement,
572
                )
573
            return
574
 
575
        self.graph_database.delete_link(
576
            link_type=GraphLinkType.UID_TO_NODE,
577
            lhs_node=old_uid,
578
            rhs_node=requirement,
579
        )
580
 
581
        if requirement.reserved_uid is not None:
582
            self.graph_database.create_link(
583
                link_type=GraphLinkType.UID_TO_NODE,
584
                lhs_node=requirement.reserved_uid,
585
                rhs_node=requirement,
586
            )
587
 
588
    def update_requirement_parent_uid(
589
        self, requirement: SDocNode, parent_uid: str, role: Optional[str]
590
    ) -> None:
591
        assert requirement.reserved_uid is not None
592
        assert isinstance(parent_uid, str), parent_uid
593
        assert role is None or len(role) > 0, role
594
        parent_nodes: OrderedSet[SDocNode] = (
595
            self.graph_database.get_link_values(
596
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
597
                lhs_node=requirement,
598
                edge=role,
599
            )
600
        )
601
        # If a relation to the parent uid through a given role already exists,
602
        # there is nothing to do.
603
        if any(node_.reserved_uid == parent_uid for node_ in parent_nodes):
604
            return
605
 
606
        parent_requirement: SDocNode = self.graph_database.get_link_value(
607
            link_type=GraphLinkType.UID_TO_NODE,
608
            lhs_node=parent_uid,
609
        )
610
 
611
        document = assert_cast(requirement.get_document(), SDocDocument)
612
        parent_requirement_document = assert_cast(
613
            parent_requirement.get_document(), SDocDocument
614
        )
615
 
616
        self.graph_database.create_link(
617
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
618
            lhs_node=requirement,
619
            rhs_node=parent_requirement,
620
            edge=role,
621
        )
622
        self.graph_database.create_link(
623
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
624
            lhs_node=parent_requirement,
625
            rhs_node=requirement,
626
            edge=role,
627
        )
628
 
629
        cycle_detector = TreeCycleDetector()
630
 
631
        def parent_cycle_traverse_(node_id: str) -> List[Any]:
632
            node = self.graph_database.get_link_value(
633
                link_type=GraphLinkType.UID_TO_NODE,
634
                lhs_node=node_id,
635
            )
636
            return list(
637
                map(
638
                    lambda node_: node_.reserved_uid,
639
                    self.graph_database.get_link_values(
640
                        link_type=GraphLinkType.NODE_TO_PARENT_NODES,
641
                        lhs_node=node,
642
                    ),
643
                )
644
            )
645
 
646
        cycle_detector.check_node(
647
            requirement.reserved_uid,
648
            parent_cycle_traverse_,
649
        )
650
 
651
        # Mark document and parent document (if different) for re-generation.
652
        assert document.meta is not None
653
        set_file_modification_time(
654
            document.meta.input_doc_full_path, datetime.datetime.today()
655
        )
656
        if parent_requirement_document != document:
657
            assert parent_requirement_document.meta is not None
658
            set_file_modification_time(
659
                parent_requirement_document.meta.input_doc_full_path,
660
                datetime.datetime.today(),
661
            )
662
 
663
    def update_node_mid(self, node: SDocNode, new_mid: str) -> None:
664
        """
665
        Update a node’s MID identifier and update all parent and child node
666
        relations to reference this new MID.
667
 
668
        The graph database contains relations between nodes. Since these
669
        relations remain unchanged, the job of this function is only to update
670
        each node's original .relations, so that they point to the new MID.
671
        """
672
 
673
        old_mid = node.reserved_mid
674
 
675
        #
676
        # Update all child nodes.
677
        #
678
        child_nodes: OrderedSet[SDocNode] = self.graph_database.get_link_values(
679
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
680
            lhs_node=node,
681
            edge=ALL_EDGES,
682
        )
683
        for child_node_ in child_nodes:
684
            child_node_document = child_node_.get_document()
685
            assert child_node_document is not None
686
 
687
            if child_node_document.config.relation_field != "MID":
688
                continue
689
 
690
            for relation_ in copy(child_node_.relations):
691
                if (
692
                    isinstance(relation_, ParentReqReference)
693
                    and relation_.ref_uid == old_mid
694
                ):
695
                    relation_.ref_uid = new_mid
696
 
697
        #
698
        # Update all parent nodes.
699
        #
700
        parent_nodes: OrderedSet[SDocNode] = (
701
            self.graph_database.get_link_values(
702
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
703
                lhs_node=node,
704
                edge=ALL_EDGES,
705
            )
706
        )
707
        for parent_node_ in parent_nodes:
708
            parent_node_document = parent_node_.get_document()
709
            assert parent_node_document is not None
710
 
711
            if parent_node_document.config.relation_field != "MID":
712
                continue
713
 
714
            for relation_ in copy(parent_node_.relations):
715
                if (
716
                    isinstance(relation_, ChildReqReference)
717
                    and relation_.ref_uid == old_mid
718
                ):
719
                    relation_.ref_uid = new_mid
720
 
721
        #
722
        # Update the node itself.
723
        #
724
        node.set_field_value(
725
            field_name="MID",
726
            form_field_index=0,
727
            value=new_mid,
728
        )
729
 
730
    def update_requirement_child_uid(
731
        self, requirement: SDocNode, child_uid: str, role: Optional[str]
732
    ) -> None:
733
        assert requirement.reserved_uid is not None
734
        assert isinstance(child_uid, str), child_uid
735
        assert role is None or len(role) > 0, role
736
        child_nodes: OrderedSet[SDocNode] = self.graph_database.get_link_values(
737
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
738
            lhs_node=requirement,
739
            edge=role,
740
        )
741
 
742
        # If a relation to the parent uid through a given role already exists,
743
        # there is nothing to do.
744
        if any(node_.reserved_uid == child_uid for node_ in child_nodes):
745
            return
746
        child_requirement = self.graph_database.get_link_value(
747
            link_type=GraphLinkType.UID_TO_NODE,
748
            lhs_node=child_uid,
749
        )
750
 
751
        document = assert_cast(requirement.get_document(), SDocDocument)
752
        child_requirement_document = assert_cast(
753
            child_requirement.get_document(), SDocDocument
754
        )
755
 
756
        self.graph_database.create_link(
757
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
758
            lhs_node=child_requirement,
759
            rhs_node=requirement,
760
            edge=role,
761
        )
762
        self.graph_database.create_link(
763
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
764
            lhs_node=requirement,
765
            rhs_node=child_requirement,
766
            edge=role,
767
        )
768
 
769
        cycle_detector = TreeCycleDetector()
770
 
771
        def child_cycle_traverse_(node_id: str) -> List[Any]:
772
            node = self.graph_database.get_link_value(
773
                link_type=GraphLinkType.UID_TO_NODE,
774
                lhs_node=node_id,
775
            )
776
            return list(
777
                map(
778
                    lambda node_: node_.reserved_uid,
779
                    self.graph_database.get_link_values(
780
                        link_type=GraphLinkType.NODE_TO_CHILD_NODES,
781
                        lhs_node=node,
782
                    ),
783
                )
784
            )
785
 
786
        cycle_detector.check_node(
787
            requirement.reserved_uid,
788
            child_cycle_traverse_,
789
        )
790
 
791
        # Mark document and parent document (if different) for re-generation.
792
        assert document.meta is not None
793
        set_file_modification_time(
794
            document.meta.input_doc_full_path, datetime.datetime.today()
795
        )
796
        if child_requirement_document != document:
797
            assert child_requirement_document.meta is not None
798
            set_file_modification_time(
799
                child_requirement_document.meta.input_doc_full_path,
800
                datetime.datetime.today(),
801
            )
802
 
803
    def update_with_anchor(self, anchor: Anchor) -> None:
804
        # By this time, we know that the validations have passed just before.
805
        existing_anchor: Optional[Anchor] = (
806
            self.graph_database.get_link_value_weak(
807
                link_type=GraphLinkType.UID_TO_NODE,
808
                lhs_node=anchor.value,
809
            )
810
        )
811
        if existing_anchor is not None:
812
            self.graph_database.delete_link(
813
                link_type=GraphLinkType.MID_TO_NODE,
814
                lhs_node=existing_anchor.mid,
815
                rhs_node=existing_anchor,
816
            )
817
            self.graph_database.delete_link(
818
                link_type=GraphLinkType.UID_TO_NODE,
819
                lhs_node=existing_anchor.value,
820
                rhs_node=existing_anchor,
821
            )
822
 
823
        self.graph_database.create_link(
824
            link_type=GraphLinkType.MID_TO_NODE,
825
            lhs_node=anchor.mid,
826
            rhs_node=anchor,
827
        )
828
        self.graph_database.create_link(
829
            link_type=GraphLinkType.UID_TO_NODE,
830
            lhs_node=anchor.value,
831
            rhs_node=anchor,
832
        )
833
 
834
    def update_disconnect_two_documents_if_no_links_left(
835
        self, document: SDocDocument, other_document: SDocDocument
836
    ) -> None:
837
        assert document != other_document
838
 
839
        for node, _ in self.document_iterators[document].all_content(
840
            print_fragments=False
841
        ):
842
            if not isinstance(node, SDocNode):
843
                continue
844
 
845
            requirement_node: SDocNode = node
846
 
847
            # If a requirement has no UID, it cannot contribute to any relation-based
848
            # connection between any two documents.
849
            if requirement_node.reserved_uid is None:
850
                continue
851
 
852
            requirement_parents: OrderedSet[SDocNode] = (
853
                self.graph_database.get_link_values(
854
                    link_type=GraphLinkType.NODE_TO_PARENT_NODES,
855
                    lhs_node=requirement_node,
856
                )
857
            )
858
 
859
            # If at least one parent or child relation points to the other
860
            # document, terminate, not deleting the link between documents.
861
            for parent_requirement in requirement_parents:
862
                parent_requirement_document: SDocDocument = assert_cast(
863
                    parent_requirement.get_document(), SDocDocument
864
                )
865
                if parent_requirement_document == other_document:
866
                    return
867
 
868
            requirement_children = self.graph_database.get_link_values(
869
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
870
                lhs_node=requirement_node,
871
            )
872
 
873
            for child_requirement_, _ in requirement_children:
874
                if child_requirement_.document == other_document:
875
                    return
876
 
877
    def delete_document(self, document: SDocDocument) -> None:
878
        assert isinstance(document, SDocDocument), document
879
 
880
        self.graph_database.delete_link(
881
            link_type=GraphLinkType.MID_TO_NODE,
882
            lhs_node=document.reserved_mid,
883
            rhs_node=document,
884
        )
885
        if document.reserved_uid is not None:
886
            self.graph_database.delete_link(
887
                link_type=GraphLinkType.UID_TO_NODE,
888
                lhs_node=document.reserved_uid,
889
                rhs_node=document,
890
            )
891
 
892
    def delete_requirement(self, requirement: SDocNode) -> None:
893
        assert isinstance(requirement, SDocNode), SDocNode
894
 
895
        self.graph_database.delete_link(
896
            link_type=GraphLinkType.MID_TO_NODE,
897
            lhs_node=requirement.reserved_mid,
898
            rhs_node=requirement,
899
        )
900
        if requirement.reserved_uid is not None:
901
            self.graph_database.delete_link(
902
                link_type=GraphLinkType.UID_TO_NODE,
903
                lhs_node=requirement.reserved_uid,
904
                rhs_node=requirement,
905
            )
906
            self.graph_database.delete_all_links(
907
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
908
                lhs_node=requirement,
909
            )
910
            self.graph_database.delete_all_links(
911
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
912
                lhs_node=requirement,
913
            )
914
 
915
    def remove_requirement_parent_uid(
916
        self, requirement: SDocNode, parent_uid: str, role: Optional[str]
917
    ) -> None:
918
        assert requirement.reserved_uid is not None
919
        assert isinstance(parent_uid, str), parent_uid
920
        assert role is None or len(role) > 0, role
921
 
922
        parent_requirement: SDocNode = self.graph_database.get_link_value(
923
            link_type=GraphLinkType.UID_TO_NODE,
924
            lhs_node=parent_uid,
925
        )
926
 
927
        self.graph_database.delete_link(
928
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
929
            lhs_node=requirement,
930
            rhs_node=parent_requirement,
931
            edge=role,
932
        )
933
        self.graph_database.delete_link(
934
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
935
            lhs_node=parent_requirement,
936
            rhs_node=requirement,
937
            edge=role,
938
        )
939
 
940
        document = assert_cast(requirement.get_document(), SDocDocument)
941
        parent_requirement_document = assert_cast(
942
            parent_requirement.get_document(), SDocDocument
943
        )
944
 
945
        # If there are no requirements linking between the documents,
946
        # remove the link.
947
        if document != parent_requirement_document:
948
            self.update_disconnect_two_documents_if_no_links_left(
949
                document, parent_requirement_document
950
            )
951
 
952
        # Mark document and parent document (if different) for re-generation.
953
        assert document.meta is not None
954
        set_file_modification_time(
955
            document.meta.input_doc_full_path, datetime.datetime.today()
956
        )
957
        if parent_requirement_document != document:
958
            assert parent_requirement_document.meta is not None
959
            set_file_modification_time(
960
                parent_requirement_document.meta.input_doc_full_path,
961
                datetime.datetime.today(),
962
            )
963
 
964
    def remove_requirement_child_uid(
965
        self, requirement: SDocNode, child_uid: str, role: Optional[str]
966
    ) -> None:
967
        assert requirement.reserved_uid is not None
968
        assert isinstance(child_uid, str), child_uid
969
        assert role is None or len(role) > 0, role
970
 
971
        child_requirement: SDocNode = self.graph_database.get_link_value(
972
            link_type=GraphLinkType.UID_TO_NODE,
973
            lhs_node=child_uid,
974
        )
975
        document: SDocDocument = assert_cast(
976
            requirement.get_document(), SDocDocument
977
        )
978
        child_requirement_document: SDocDocument = assert_cast(
979
            child_requirement.get_document(), SDocDocument
980
        )
981
 
982
        self.graph_database.delete_link(
983
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
984
            lhs_node=requirement,
985
            rhs_node=child_requirement,
986
            edge=role,
987
        )
988
        self.graph_database.delete_link(
989
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
990
            lhs_node=child_requirement,
991
            rhs_node=requirement,
992
            edge=role,
993
        )
994
 
995
        # If there are no requirements linking between the documents,
996
        # remove the link.
997
        if document != child_requirement_document:
998
            self.update_disconnect_two_documents_if_no_links_left(
999
                document, child_requirement_document
1000
            )
1001
 
1002
        # Mark document and parent document (if different) for re-generation.
1003
        assert document.meta is not None
1004
        set_file_modification_time(
1005
            document.meta.input_doc_full_path, datetime.datetime.today()
1006
        )
1007
        if child_requirement_document != document:
1008
            assert child_requirement_document.meta is not None
1009
            set_file_modification_time(
1010
                child_requirement_document.meta.input_doc_full_path,
1011
                datetime.datetime.today(),
1012
            )
1013
 
1014
    def remove_inline_link(self, inline_link: InlineLink) -> None:
1015
        sections_with_incoming_links = (
1016
            self.graph_database.get_link_values_reverse(
1017
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1018
                rhs_node=inline_link,
1019
            )
1020
        )
1021
 
1022
        for node_with_incoming_links in list(sections_with_incoming_links):
1023
            self.graph_database.delete_link(
1024
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1025
                lhs_node=node_with_incoming_links,
1026
                rhs_node=inline_link,
1027
            )
1028
 
1029
        self.graph_database.delete_link(
1030
            link_type=GraphLinkType.MID_TO_NODE,
1031
            lhs_node=inline_link.reserved_mid,
1032
            rhs_node=inline_link,
1033
        )
1034
 
1035
    def remove_anchor_by_uid(self, anchor_uid: str) -> None:
1036
        anchor: Anchor = self.graph_database.get_link_value(
1037
            link_type=GraphLinkType.UID_TO_NODE,
1038
            lhs_node=anchor_uid,
1039
        )
1040
        self.graph_database.delete_link(
1041
            link_type=GraphLinkType.MID_TO_NODE,
1042
            lhs_node=anchor.mid,
1043
            rhs_node=anchor,
1044
        )
1045
        self.graph_database.delete_link(
1046
            link_type=GraphLinkType.UID_TO_NODE,
1047
            lhs_node=anchor_uid,
1048
            rhs_node=anchor,
1049
        )
1050
 
1051
    def validate_node_against_anchors(
1052
        self,
1053
        *,
1054
        node: Union[SDocDocument, SDocNode, None],
1055
        new_anchors: List[Anchor],
1056
    ) -> None:
1057
        assert node is None or isinstance(node, (SDocDocument, SDocNode))
1058
        assert isinstance(new_anchors, list)
1059
 
1060
        # Check that this node does not have duplicated anchors.
1061
        new_anchor_uids = set()
1062
        for anchor in new_anchors:
1063
            if anchor.value in new_anchor_uids:
1064
                raise SingleValidationError(
1065
                    "A node cannot have two anchors with "
1066
                    f"the same identifier: {anchor.value}."
1067
                )
1068
            new_anchor_uids.add(anchor.value)
1069
 
1070
        # If the node is new, the validation is easier: we just need to make
1071
        # sure that there are no existing anchors with the UIDs brought
1072
        # by the new anchors.
1073
        if node is None:
1074
            for anchor_uid in new_anchor_uids:
1075
                if self.graph_database.has_any_link(
1076
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1077
                ):
1078
                    raise SingleValidationError(
1079
                        "A node contains an anchor that already exists: "
1080
                        f"{anchor_uid}."
1081
                    )
1082
            return
1083
 
1084
        # If the node is an existing node, we need to check that:
1085
        # 1) If some of the new anchors already exist in the project tree, we
1086
        #    need to ensure that they exist in the current node, otherwise we
1087
        #    raise a duplication validation error.
1088
        # 2) If the new anchors do not contain some of the existing node's
1089
        #    current anchors, this means these anchors are being removed. In
1090
        #    that case, we need to check if these anchors are used by any LINKs,
1091
        #    raising a validation if they do.
1092
        existing_node_anchor_uids = set()
1093
 
1094
        # FIXME: No test reaches this for Section or Document.
1095
        assert isinstance(node, SDocNode)
1096
        for node_anchor_ in node.get_anchors():
1097
            existing_node_anchor_uids.add(node_anchor_.value)
1098
 
1099
        #
1100
        # Validation 1: Assert all UIDs are either:
1101
        # a) new
1102
        # b) exist in this node
1103
        # c) raise a duplication error.
1104
        #
1105
        for anchor_uid in new_anchor_uids:
1106
            if (
1107
                self.graph_database.has_any_link(
1108
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1109
                )
1110
                and anchor_uid not in existing_node_anchor_uids
1111
            ):
1112
                duplicate_anchor: Anchor = self.graph_database.get_link_value(
1113
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1114
                )
1115
                node_with_duplicate_anchor: SDocNode = assert_cast(
1116
                    duplicate_anchor.parent_node(), SDocNode
1117
                )
1118
                raise SingleValidationError(
1119
                    "Another node contains an anchor with the same UID: "
1120
                    f"{anchor_uid}. {node_with_duplicate_anchor.get_display_node_type()}: "
1121
                    f"{node_with_duplicate_anchor.get_display_title()}."
1122
                )
1123
 
1124
        #
1125
        # Validation 2: Check that removed anchors do not have any incoming
1126
        # links.
1127
        #
1128
        to_be_removed_anchor_uids = existing_node_anchor_uids - new_anchor_uids
1129
        for to_be_removed_anchor_uid_ in to_be_removed_anchor_uids:
1130
            to_be_removed_anchor: Anchor = self.graph_database.get_link_value(
1131
                link_type=GraphLinkType.UID_TO_NODE,
1132
                lhs_node=to_be_removed_anchor_uid_,
1133
            )
1134
            incoming_links = self.graph_database.get_link_values(
1135
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1136
                lhs_node=to_be_removed_anchor.reserved_mid,
1137
            )
1138
            if incoming_links is not None and len(incoming_links) > 0:
1139
                incoming_link: InlineLink = incoming_links[0]
1140
                incoming_link_parent_node = incoming_link.parent_node()
1141
                raise SingleValidationError(
1142
                    f"Cannot remove anchor with UID "
1143
                    f"'{incoming_link.link}' because it has incoming "
1144
                    f"links. Containing node: "
1145
                    f"{incoming_link_parent_node.get_display_node_type()}: "
1146
                    f"'{incoming_link_parent_node.get_display_title()}'."
1147
                )
1148
 
1149
    def validate_can_remove_node(
1150
        self, *, node: Union[SDocNode, Anchor]
1151
    ) -> None:
1152
        incoming_links: Optional[List[InlineLink]] = self.get_incoming_links(
1153
            node
1154
        )
1155
        if incoming_links is not None and len(incoming_links) > 0:
1156
            link_list_message = ", ".join(
1157
                map(
1158
                    lambda l_: (
1159
                        f"'{l_.parent_node().get_display_title()}' -> '{l_.link}'"
1160
                    ),
1161
                    incoming_links,
1162
                )
1163
            )
1164
            raise SingleValidationError(
1165
                f"Cannot remove node '{node.get_display_title()}' with incoming LINKs from: {link_list_message}."
1166
            )
1167
        if isinstance(node, SDocNode):
1168
            child_nodes: List[SDocNode] = self.get_children_requirements(node)
1169
            if child_nodes is not None and len(child_nodes) > 0:
1170
                nodes_list_message = ", ".join(
1171
                    map(
1172
                        lambda n_: "'" + n_.get_display_title() + "'",
1173
                        child_nodes,
1174
                    )
1175
                )
1176
                raise SingleValidationError(
1177
                    f"Cannot remove node '{node.get_display_title()}' "
1178
                    f"with incoming relations from:\n{nodes_list_message}."
1179
                )
1180
 
1181
    def validate_can_remove_document(self, document: SDocDocument) -> None:
1182
        errors: List[str] = []
1183
 
1184
        if document.document_is_included():
1185
            including_document = document.get_including_document()
1186
            if including_document is not None:
1187
                errors.append(
1188
                    f"Cannot remove document '{document.get_display_title()}' because it is included in document '{including_document.get_display_title()}'."
1189
                )
1190
            else:
1191
                errors.append(
1192
                    f"Cannot remove document '{document.get_display_title()}' because it is included in another document."
1193
                )
1194
 
1195
        incoming_links: Optional[List[InlineLink]] = self.get_incoming_links(
1196
            document
1197
        )
1198
        if incoming_links is not None and len(incoming_links) > 0:
1199
            link_list_message = ", ".join(
1200
                map(
1201
                    lambda l_: (
1202
                        f"'{l_.parent_node().get_display_title()}' -> '{l_.link}'"
1203
                    ),
1204
                    incoming_links,
1205
                )
1206
            )
1207
            errors.append(
1208
                f"Cannot remove document '{document.get_display_title()}' with incoming LINKs from: {link_list_message}."
1209
            )
1210
 
1211
        document_iterator = SDocDocumentIterator(document=document)
1212
        for document_node_, _ in document_iterator.all_content(
1213
            print_fragments=True
1214
        ):
1215
            if not isinstance(document_node_, SDocNode):
1216
                continue
1217
 
1218
            nodes_with_incoming_links = [
1219
                document_node_
1220
            ] + document_node_.get_anchors()
1221
            for node_ in nodes_with_incoming_links:
1222
                try:
1223
                    self.validate_can_remove_node(node=node_)
1224
                except SingleValidationError as exception_:
1225
                    errors.append(exception_.args[0])
1226
 
1227
            parent_nodes: List[SDocNode] = self.get_parent_requirements(
1228
                document_node_
1229
            )
1230
            if len(parent_nodes) > 0:
1231
                nodes_list_message = ", ".join(
1232
                    map(
1233
                        lambda n_: "'" + n_.get_display_title() + "'",
1234
                        parent_nodes,
1235
                    )
1236
                )
1237
                errors.append(
1238
                    f"Cannot remove document '{document_node_.get_display_title()}' "
1239
                    f"with incoming relations from:\n{nodes_list_message}."
1240
                )
1241
 
1242
        if len(errors) > 0:
1243
            raise MultipleValidationErrorAsList("NOT_RELEVANT", errors)
1244
 
1245
    def clone_to_bundle_document(
1246
        self, project_config: ProjectConfig
1247
    ) -> Tuple["TraceabilityIndex", SDocDocument]:
1248
        """
1249
        Clone this traceability index and create a new bundle document.
1250
 
1251
        The only use case for this method is the generation of a bundle document.
1252
        Since the bundle document does not exist on file system, some parameters
1253
        are set artificially:
1254
        - The bundle is assumed to be an input file in the root input folder.
1255
        - The bundle is generated to the root of the output folder (level=0).
1256
        - Some variables do not contribute (yet) to the final result, so they
1257
          are marked as NOT_RELEVANT.
1258
 
1259
        @relation(SDOC-SRS-51, scope=function)
1260
        """
1261
        traceability_index_copy = deepcopy(self)
1262
        bundle_document = SDocDocument(
1263
            mid=None,
1264
            title=project_config.project_title,
1265
            config=None,
1266
            view=None,
1267
            grammar=None,
1268
            section_contents=[],
1269
            is_bundle_document=True,
1270
        )
1271
        bundle_document.config = DocumentConfig.default_config(bundle_document)
1272
 
1273
        if (
1274
            project_config.bundle_document_uid is not None
1275
            and len(project_config.bundle_document_uid) > 0
1276
        ):
1277
            bundle_document.config.uid = project_config.bundle_document_uid
1278
 
1279
        if (
1280
            project_config.bundle_document_version is not None
1281
            and len(project_config.bundle_document_version) > 0
1282
        ):
1283
            bundle_document.config.version = (
1284
                project_config.bundle_document_version
1285
            )
1286
 
1287
        if (
1288
            project_config.bundle_document_date is not None
1289
            and len(project_config.bundle_document_date) > 0
1290
        ):
1291
            bundle_document.config.date = project_config.bundle_document_date
1292
 
1293
        bundle_document.meta = DocumentMeta(
1294
            level=0,
1295
            file_tree_mount_folder="NOT_RELEVANT",
1296
            document_filename="bundle.sdoc",
1297
            document_filename_base="bundle",
1298
            input_doc_full_path="NOT_RELEVANT",
1299
            input_doc_rel_path=SDocRelativePath("bundle.sdoc"),
1300
            input_doc_dir_rel_path=SDocRelativePath(""),
1301
            input_doc_assets_dir_rel_path=SDocRelativePath("NOT_RELEVANT"),
1302
            output_document_dir_full_path=project_config.export_output_html_root,
1303
            output_document_dir_rel_path=SDocRelativePath(""),
1304
        )
1305
        traceability_index_copy.document_iterators[bundle_document] = (
1306
            SDocDocumentIterator(bundle_document)
1307
        )
1308
        for document_ in traceability_index_copy.document_tree.document_list:
1309
            # Ignore all included documents. They are anyway included by
1310
            # the including documents.
1311
            if document_.document_is_included():
1312
                continue
1313
 
1314
            assert document_.ng_including_document_reference is not None
1315
            document_.ng_including_document_reference.set_document(
1316
                bundle_document
1317
            )
1318
            bundle_document.section_contents.append(document_)
1319
        traceability_index_copy.document_tree.document_list = [bundle_document]
1320
        bundle_document.ng_including_document_reference = DocumentReference()
1321
        return traceability_index_copy, bundle_document