Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ MedHarness follows [Semantic Versioning](https://semver.org/):

## [Unreleased]

## [0.54.2] — 2026-10-04

### Changed

- **One source for "which fields are links": the types' own declarations.** `dhfkit/traceability.py`
and `item_store.py` each kept a hand-written list on top of the schema (the same duplication that once
left five relationship fields unchecked). Both are gone: dangling-link detection, cycle detection and the
write-time link check read the fields a doc type declares (`relationship`, `item_multiselect`). A type
that declares no link fields has no links; the shipped types declare theirs. `find_dangling_links` and
`find_link_cycles` now take the fields explicitly.

## [0.54.1] — 2026-10-04

### Fixed
Expand Down
13 changes: 1 addition & 12 deletions dhfkit/item_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,6 @@
from dhfkit.adapter import DHFAdapter, resolve_adapter
from dhfkit.id_generator import get_next_id

# V-model traceability link fields — used for orphan and coverage checks.
_TRACEABILITY_LINK_FIELDS = (
"derives_from", "implements", "mitigates", "satisfies",
"guided_by", "informs", "design", "verifies", "validates",
"mitigated_by", "module",
)
# Full set of relationship fields on any item — superset of traceability fields.
_ITEM_LINK_FIELDS = _TRACEABILITY_LINK_FIELDS + (
"affected_items", "affected_risk_items",
"target_release", "found_in_release", "fixed_in_release",
)
# What a branch changed, deleted items included: they stay named after they are gone.
_HISTORICAL_LINK_FIELDS = frozenset({"affected_items"})
_UID_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+$")
Expand Down Expand Up @@ -169,7 +158,7 @@ def _validate_item_links(self, data: dict, written: set[str] | None = None) -> N
doc_type = self._config.doc_type_of(uid) if uid else None
declared = tuple(self._config.link_properties(doc_type.code)) if doc_type else ()
errors = []
for field in dict.fromkeys(_ITEM_LINK_FIELDS + declared):
for field in declared:
val = data.get(field)
if not val:
continue
Expand Down
15 changes: 9 additions & 6 deletions dhfkit/tests/test_dangling_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,23 @@
# find_dangling_links
# ---------------------------------------------------------------------------

FIELDS = ("derives_from", "implements", "mitigates", "verifies")


class TestFindDanglingLinks:
def test_clean_items_have_none(self) -> None:
items = [
{"id": "SYS-001"},
{"id": "SRS-001", "derives_from": ["SYS-001"]},
]
assert find_dangling_links(items) == []
assert find_dangling_links(items, FIELDS) == []

def test_detects_missing_target(self) -> None:
items = [
{"id": "SYS-001"},
{"id": "SRS-001", "derives_from": ["SYS-999"]},
]
assert find_dangling_links(items) == [
assert find_dangling_links(items, FIELDS) == [
{"source": "SRS-001", "field": "derives_from", "target": "SYS-999"},
]

Expand All @@ -40,7 +43,7 @@ def test_checks_every_link_field(self) -> None:
{"id": "RCM-001", "mitigates": ["RISK-404"], "implements": ["SYS-404"]},
{"id": "TC-001", "verifies": ["SRS-404"]},
]
found = {(d["field"], d["target"]) for d in find_dangling_links(items)}
found = {(d["field"], d["target"]) for d in find_dangling_links(items, FIELDS)}
assert found == {
("mitigates", "RISK-404"),
("implements", "SYS-404"),
Expand All @@ -52,7 +55,7 @@ def test_partial_link_list_reports_only_bad_entry(self) -> None:
{"id": "SYS-001"},
{"id": "SRS-001", "derives_from": ["SYS-001", "SYS-999"]},
]
dangling = find_dangling_links(items)
dangling = find_dangling_links(items, FIELDS)
assert len(dangling) == 1
assert dangling[0]["target"] == "SYS-999"

Expand All @@ -61,14 +64,14 @@ def test_empty_and_missing_fields_are_safe(self) -> None:
{"id": "SYS-001", "derives_from": [], "mitigates": None},
{"id": "SYS-002"},
]
assert find_dangling_links(items) == []
assert find_dangling_links(items, FIELDS) == []

def test_output_is_sorted(self) -> None:
items = [
{"id": "SRS-002", "derives_from": ["ZZZ-1"]},
{"id": "SRS-001", "derives_from": ["ZZZ-2"]},
]
assert [d["source"] for d in find_dangling_links(items)] == ["SRS-001", "SRS-002"]
assert [d["source"] for d in find_dangling_links(items, FIELDS)] == ["SRS-001", "SRS-002"]


# ---------------------------------------------------------------------------
Expand Down
21 changes: 2 additions & 19 deletions dhfkit/traceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,7 @@

from collections.abc import Iterable

from typing import Any

def _prefix_of(uid: str) -> str:
parts = uid.rsplit("-", 1)
return parts[0] + "-" if len(parts) == 2 else ""

LINK_FIELDS = (
"derives_from", "implements", "guided_by", "informs", "design",
"mitigates", "satisfies", "verifies", "validates", "module",
"affected_risk_items", "affected_items",
)

def find_dangling_links(
items: list[dict], link_fields: Iterable[str] | None = None
) -> list[dict]:
def find_dangling_links(items: list[dict], link_fields: Iterable[str]) -> list[dict]:
"""Find traceability links pointing at IDs that do not exist in the DHF.

A dangling link is not the same failure as missing coverage: the author did
Expand All @@ -38,10 +24,7 @@ def find_dangling_links(
Returns [{"source", "field", "target"}] sorted for stable output.
"""
known = {item["id"] for item in items}
# The schema decides what a link is; LINK_FIELDS is only the floor. Two
# hand-written lists used to decide it between them and disagreed, leaving
# five of the nine relationship fields in the shipped schema unchecked.
fields = sorted(set(LINK_FIELDS) | set(link_fields or ()))
fields = sorted(set(link_fields))
dangling: list[dict] = []
for item in items:
source = item.get("id", "")
Expand Down
17 changes: 6 additions & 11 deletions medharness/services/traceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from dhfkit.item_type import ItemType
from dhfkit.models.config import RequiredTraceabilityRule, TraceabilityMatrix
from dhfkit.traceability import LINK_FIELDS, find_dangling_links
from dhfkit.traceability import find_dangling_links

def _ids(value: Any) -> list[str]:
values = [value] if isinstance(value, str) else value if isinstance(value, list) else []
Expand Down Expand Up @@ -245,9 +245,7 @@ def build_module_map(items: list[dict], config: Any) -> list[dict]:

return sorted(result, key=lambda x: x["module_id"])

def find_link_cycles(
items: list[dict], link_fields: Iterable[str] | None = None
) -> list[list[str]]:
def find_link_cycles(items: list[dict], link_fields: Iterable[str]) -> list[list[str]]:
"""Find traceability links that form a cycle.

The V-model is directed: a customer requirement gives rise to a system
Expand All @@ -258,7 +256,7 @@ def find_link_cycles(
Returns each cycle as the list of IDs in it, rotated to start at its lowest
ID so the same cycle reads the same way between runs.
"""
fields = sorted(set(LINK_FIELDS) | set(link_fields or ()))
fields = sorted(set(link_fields))
known = {str(i.get("id")) for i in items if i.get("id")}

graph = nx.DiGraph()
Expand Down Expand Up @@ -337,12 +335,9 @@ def check_traceability(items: list[dict], config: Any) -> dict:
"passed": len(uncovered) == 0,
})

dangling = find_dangling_links(
items, getattr(config, "relationship_fields", lambda: ())()
)
cycles = find_link_cycles(
items, getattr(config, "relationship_fields", lambda: ())()
)
link_fields = config.relationship_fields()
dangling = find_dangling_links(items, link_fields)
cycles = find_link_cycles(items, link_fields)
mistyped = find_mistyped_links(items, config)
passed = (
required_result["passed"]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "medharness"
version = "0.54.1"
version = "0.54.2"
description = "Open-source design-controlled development infrastructure for medical device and SaMD teams — includes dhfkit"
readme = "README.md"
license = { text = "MIT" }
Expand Down
17 changes: 17 additions & 0 deletions tests/integration/test_a_link_must_point_at_an_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,20 @@ def test_an_update_that_leaves_an_old_dangling_link_alone_still_goes_through(dhf
result = _item(dhf, "update", "CR-001", "--data", json.dumps({"description": "Reworded."}))

assert result.exit_code == 0, result.output


def test_a_link_field_a_project_declares_is_checked_too(dhf: Path) -> None:
import yaml

(dhf / "config" / "doc_types").mkdir(exist_ok=True)
(dhf / "config" / "doc_types" / "hwr.yaml").write_text(yaml.safe_dump({
"code": "HWR", "name": "Hardware requirement", "prefix": "HWR-", "directory": "hwr",
"properties": ["id", {"name": "title", "format": "short_text"},
{"name": "refines", "format": "relationship", "target_types": ["SYS"]}]}))

refused = _item(dhf, "create", "--type", "HWR", "--data", json.dumps({"title": "T", "refines": ["SYS-999"]}))
assert refused.exit_code != 0
assert "refines: 'SYS-999' does not exist" in refused.output + (refused.stderr or "")

accepted = _item(dhf, "create", "--type", "HWR", "--data", json.dumps({"title": "T", "refines": ["SYS-001"]}))
assert accepted.exit_code == 0, accepted.output
6 changes: 4 additions & 2 deletions tests/unit/test_cr_does_not_affect_itself.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from medharness.services.cr_impact import _record_design_impact_in_cr
from medharness.scaffold import replace_placeholders, scaffold_dhf

LINKS = ("derives_from", "implements", "affected_items")


@pytest.fixture
def dhf(tmp_path: Path) -> Path:
Expand Down Expand Up @@ -78,15 +80,15 @@ def test_no_cycle_is_produced(self, dhf: Path) -> None:
{"created": [], "updated": ["CR-001", "SYS-001"], "deleted": []},
)
items = ItemStore(dhf).list_items()
assert find_link_cycles(items) == []
assert find_link_cycles(items, LINKS) == []

def test_the_unfixed_shape_would_have_cycled(self, dhf: Path) -> None:
"""Pins why this matters rather than trusting the fix on its own."""
from medharness.services.traceability import find_link_cycles

as_written_before = [{"id": "CR-001", "affected_items": ["CR-001", "SYS-001"]},
{"id": "SYS-001"}]
assert find_link_cycles(as_written_before) == [["CR-001"]]
assert find_link_cycles(as_written_before, LINKS) == [["CR-001"]]


class TestTheSuiteExercisesANonEmptyChangeSet:
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/test_cycle_is_reported.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from dhfkit.models.config import DocTypeConfig, ProjectConfig
from medharness.services.traceability import check_traceability

LINK = {"name": "derives_from", "format": "relationship"}
CONFIG = ProjectConfig(
doc_types=[
DocTypeConfig(code="SYS", name="System Requirement", prefix="SYS-"),
DocTypeConfig(code="SRS", name="Software Requirement", prefix="SRS-"),
DocTypeConfig(code="SYS", name="System Requirement", prefix="SYS-", properties=[LINK]),
DocTypeConfig(code="SRS", name="Software Requirement", prefix="SRS-", properties=[LINK]),
],
required_traceability=[],
)
Expand Down
16 changes: 9 additions & 7 deletions tests/unit/test_traceability_cycles.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,30 @@
from medharness.services.traceability import find_link_cycles
from medharness.scaffold import replace_placeholders, scaffold_dhf

LINKS = ("derives_from", "implements", "affected_items")


class TestFindLinkCycles:
def test_a_two_item_cycle(self) -> None:
items = [{"id": "SYS-001", "derives_from": ["SRS-001"]},
{"id": "SRS-001", "derives_from": ["SYS-001"]}]
assert find_link_cycles(items) == [["SRS-001", "SYS-001"]]
assert find_link_cycles(items, LINKS) == [["SRS-001", "SYS-001"]]

def test_a_longer_cycle(self) -> None:
items = [{"id": "A-1", "derives_from": ["A-2"]},
{"id": "A-2", "derives_from": ["A-3"]},
{"id": "A-3", "derives_from": ["A-1"]}]
assert find_link_cycles(items) == [["A-1", "A-2", "A-3"]]
assert find_link_cycles(items, LINKS) == [["A-1", "A-2", "A-3"]]

def test_a_self_link_is_the_degenerate_cycle(self) -> None:
"""An item deriving from itself is as wrong as a two-item loop."""
assert find_link_cycles([{"id": "S-1", "derives_from": ["S-1"]}]) == [["S-1"]]
assert find_link_cycles([{"id": "S-1", "derives_from": ["S-1"]}], LINKS) == [["S-1"]]

def test_a_directed_chain_is_not_a_cycle(self) -> None:
items = [{"id": "SYS-001"},
{"id": "SRS-001", "derives_from": ["SYS-001"]},
{"id": "SRS-002", "derives_from": ["SYS-001"]}]
assert find_link_cycles(items) == []
assert find_link_cycles(items, LINKS) == []

def test_a_dangling_link_yields_no_cycle(self) -> None:
"""A link to something absent belongs to find_dangling_links.
Expand All @@ -61,21 +63,21 @@ def test_a_dangling_link_yields_no_cycle(self) -> None:
{"id": "SRS-001", "derives_from": ["SYS-404"]},
{"id": "SRS-002", "derives_from": ["SYS-404", "SRS-001"]},
]
assert find_link_cycles(items) == []
assert find_link_cycles(items, LINKS) == []

def test_one_cycle_is_reported_once(self) -> None:
"""Entering the same loop from two places is still one finding."""
items = [{"id": "A-1", "derives_from": ["A-2"]},
{"id": "A-2", "derives_from": ["A-1"]},
{"id": "B-1", "derives_from": ["A-1"]}]
assert find_link_cycles(items) == [["A-1", "A-2"]]
assert find_link_cycles(items, LINKS) == [["A-1", "A-2"]]

def test_the_ordering_is_stable(self) -> None:
"""Each cycle starts from its lowest ID, so a re-run reads the same."""
forward = [{"id": "A-2", "derives_from": ["A-1"]},
{"id": "A-1", "derives_from": ["A-2"]}]
reversed_ = list(reversed(forward))
assert find_link_cycles(forward) == find_link_cycles(reversed_) == [["A-1", "A-2"]]
assert find_link_cycles(forward, LINKS) == find_link_cycles(reversed_, LINKS) == [["A-1", "A-2"]]


class TestTheGateReportsIt:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading