diff --git a/doorstop/core/document.py b/doorstop/core/document.py
index 939d90cbb..773a90ed0 100644
--- a/doorstop/core/document.py
+++ b/doorstop/core/document.py
@@ -186,11 +186,14 @@ def load(self, reload=False):
self._data[key] = value.strip()
elif key == "digits":
self._data[key] = int(value) # type: ignore
+ elif key == "mapped_to":
+ self._data[key] = value.strip()
else:
- msg = "unexpected document setting '{}' in: {}".format(
- key, self.config
+ log.debug(
+ "custom document attribute found: {} = {}".format(key, value)
)
- raise DoorstopError(msg)
+ # custom attribute
+ self._data[key] = value
except (AttributeError, TypeError, ValueError):
msg = "invalid value for '{}' in: {}".format(key, self.config)
raise DoorstopError(msg)
@@ -437,6 +440,10 @@ def index(self):
log.info("deleting {} index...".format(self))
common.delete(self.index)
+ def attribute(self, attrib):
+ """Get the item's custom attribute."""
+ return self._data.get(attrib)
+
# actions ################################################################
# decorators are applied to methods in the associated classes
diff --git a/doorstop/core/item.py b/doorstop/core/item.py
index 9797b8e9e..18df58ed3 100644
--- a/doorstop/core/item.py
+++ b/doorstop/core/item.py
@@ -728,10 +728,18 @@ def find_child_items_and_documents(self, document=None, tree=None, find_all=True
tree = tree or self.tree
if not document or not tree:
return child_items, child_documents
+
+ # get list of mapped documents
+ mapped_document_prefixes = document.attribute("mapped_to") if document else []
+ if not mapped_document_prefixes:
+ mapped_document_prefixes = []
+
# Find child objects
log.debug("finding item {}'s child objects...".format(self))
for document2 in tree:
- if document2.parent == document.prefix:
+ if (document2.parent == document.prefix) or (
+ document2.prefix in mapped_document_prefixes
+ ):
child_documents.append(document2)
# Search for child items unless we only need to find one
if not child_items or find_all:
diff --git a/doorstop/core/tests/__init__.py b/doorstop/core/tests/__init__.py
index c51863b3f..ad7005669 100644
--- a/doorstop/core/tests/__init__.py
+++ b/doorstop/core/tests/__init__.py
@@ -4,7 +4,7 @@
import logging
import os
-from typing import List
+from typing import Dict, List
from unittest.mock import MagicMock, Mock, patch
from doorstop.core.base import BaseFileObject
@@ -95,6 +95,7 @@ def __init__(self):
self.prefix = "RQ"
self._items: List[Item] = []
self.extended_reviewed: List[str] = []
+ self._data: Dict[str, str] = {}
def __iter__(self):
yield from self._items
@@ -102,6 +103,12 @@ def __iter__(self):
def set_items(self, items):
self._items = items
+ def set_data(self, data):
+ self._data = data
+
+ def attribute(self, name):
+ return self._data.get(name)
+
class MockDocumentSkip(MockDocument): # pylint: disable=W0223,R0902
"""Mock Document class that is always skipped in tree placement."""
diff --git a/doorstop/core/tests/test_document.py b/doorstop/core/tests/test_document.py
index ebc3883d8..9725eaad6 100644
--- a/doorstop/core/tests/test_document.py
+++ b/doorstop/core/tests/test_document.py
@@ -159,8 +159,8 @@ def test_load_invalid(self):
def test_load_unknown(self):
"""Verify loading a document config with an unknown key fails."""
self.document._file = YAML_UNKNOWN
- msg = "^unexpected document setting 'John' in: .*\\.doorstop.yml$"
- self.assertRaisesRegex(DoorstopError, msg, self.document.load)
+ self.document.load()
+ self.assertEqual("Doe", self.document.attribute("John"))
def test_load_unknown_attributes(self):
"""Verify loading a document config with unknown attributes fails."""
diff --git a/doorstop/core/tree.py b/doorstop/core/tree.py
index 156f75b1b..7112a3872 100644
--- a/doorstop/core/tree.py
+++ b/doorstop/core/tree.py
@@ -627,9 +627,16 @@ def _draw_line(self):
def _draw_lines(self, encoding, html_links=False):
"""Generate lines of the tree structure."""
# Build parent prefix string (`getattr` to enable mock testing)
- prefix = getattr(self.document, "prefix", "") or str(self.document)
+ prefix_link = prefix = getattr(self.document, "prefix", "") or str(
+ self.document
+ )
+
+ attribute_fn = getattr(self.document, "attribute", None)
+ mapped = attribute_fn("mapped_to") if callable(attribute_fn) else None
+
+ prefix += " (" + ",".join(mapped) + ")" if mapped else ""
if html_links:
- prefix = '{0}'.format(prefix)
+ prefix = '{1}'.format(prefix_link, prefix)
yield prefix
# Build child prefix strings
for count, child in enumerate(self.children, start=1):
diff --git a/doorstop/core/validators/item_validator.py b/doorstop/core/validators/item_validator.py
index 94abfbc48..c240a88a1 100644
--- a/doorstop/core/validators/item_validator.py
+++ b/doorstop/core/validators/item_validator.py
@@ -198,7 +198,13 @@ def _get_issues_both(self, item, document, tree, skip):
# Verify an item is being linked to (child links)
if settings.CHECK_CHILD_LINKS and item.normative:
- find_all = settings.CHECK_CHILD_LINKS_STRICT or False
+
+ mapped_document_prefixes = document.attribute("mapped_to")
+ if not mapped_document_prefixes:
+ mapped_document_prefixes = []
+
+ find_all = settings.CHECK_CHILD_LINKS_STRICT or mapped_document_prefixes
+
items, documents = item.find_child_items_and_documents(
document=document, tree=tree, find_all=find_all
)
@@ -209,13 +215,64 @@ def _get_issues_both(self, item, document, tree, skip):
msg = "skipping issues against document %s..."
log.debug(msg, child_document)
continue
- msg = "no links from child document: {}".format(child_document)
+
+ if child_document.prefix in mapped_document_prefixes:
+ msg = "no links at all, missing mapped document: {}".format(
+ child_document
+ )
+ else:
+ msg = "no links at all, missing child document: {}".format(
+ child_document
+ )
yield DoorstopWarning(msg)
- elif settings.CHECK_CHILD_LINKS_STRICT:
+
+ # here items are found but no strict checking is enabled
+ # only check "mapped_to" as mandatory links
+ else:
prefix = [item.document.prefix for item in items]
- for child in document.children:
+
+ found = False
+ not_found_list = []
+
+ # check if at least on of the normal children exist
+ for child_document in documents:
+ if child_document.prefix in skip:
+ msg = "skipping issues against document %s..."
+ log.debug(msg, child_document)
+ continue
+
+ # handle mapped documents later
+ if child_document.prefix in mapped_document_prefixes:
+ continue
+
+ if child_document.prefix in prefix:
+ # found at least one link from child document
+ found = True
+ else:
+ not_found_list.append(child_document.prefix)
+
+ # not found anything but not strict: accept a link from any child document
+ if not found and not settings.CHECK_CHILD_LINKS_STRICT:
+ for d in not_found_list:
+ msg = "links found, missing at lest one document: {}".format(d)
+ yield DoorstopWarning(msg)
+
+ if settings.CHECK_CHILD_LINKS_STRICT:
+ # if strict check: report any document with no child links
+ for d in not_found_list:
+ msg = "no links from document: {}".format(d)
+ yield DoorstopWarning(msg)
+
+ # handle mapped documents: they are treated like "strict"
+ for child in mapped_document_prefixes:
+ if child in skip:
+ msg = "skipping issues against mapped document %s..."
+ log.debug(msg, child)
+ continue
+
if child in skip:
continue
+
if child not in prefix:
- msg = "no links from document: {}".format(child)
+ msg = "no links from mapped document: {}".format(child)
yield DoorstopWarning(msg)