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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ MedHarness follows [Semantic Versioning](https://semver.org/):

## [Unreleased]

## [0.50.1] — 2026-10-03

### Fixed

- `possible_duplicate` no longer fires on the CR being planned (intake creates it, the model does
not) or against `cancelled` and `rejected` items, so re-running intake for an issue whose earlier
CRs were cancelled no longer warns that the new CR reads like the old one.

## [0.50.0] — 2026-10-03

### Added
Expand Down
14 changes: 12 additions & 2 deletions medharness/services/design_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,15 @@ def _check_impact_analysis(cr_item: dict | None, items: list[dict], config,
return errors or impact_analysis_closure_errors(cr_item, items, config, changed_items)


WITHDRAWN_STATES = {"cancelled", "rejected"}


def check_near_duplicates(dhf_path: Path, changed_items: dict[str, list[str]]) -> list[dict]:
"""Warnings for created items that read like an existing item of the same type."""
"""Warnings for created items that read like an existing item of the same type.

The CR being planned is created by intake, not by the model, and a withdrawn item is
not something to update instead, so neither is compared.
"""
from medharness.services.impact import NEAR_DUPLICATE_RATIO, closest_same_type

_api, _ = _load_api()
Expand All @@ -605,9 +612,12 @@ def check_near_duplicates(dhf_path: Path, changed_items: dict[str, list[str]]) -
return []
by_id = {it["id"]: it for it in items}
created = set(changed_items.get("created", []))
withdrawn = {it["id"] for it in items if str(it.get("status") or "") in WITHDRAWN_STATES}
warnings = []
for uid in sorted(created):
closest = closest_same_type(by_id[uid], items, created) if uid in by_id else None
if uid not in by_id or by_id[uid].get("type") == "CR":
continue
closest = closest_same_type(by_id[uid], items, created | withdrawn)
if closest and closest[1] >= NEAR_DUPLICATE_RATIO:
warnings.append({
"code": "possible_duplicate",
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.50.0"
version = "0.50.1"
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_ai_stages_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,23 @@ def test_an_existing_item_that_gains_many_lines_is_a_warning(self, project: Path
def test_a_one_line_change_to_an_existing_item_is_not(self, project: Path, tmp_path: Path) -> None:
assert self._edit_swdd(tmp_path, "One changed line.") == []

def _copy_of(self, uid: str, item_type: str, **extra) -> str:
item = json.loads(_run("item", "get", uid).stdout)
copy = {k: item[k] for k in ("title", "content", "category", "satisfies", "verification_method",
"verification_criteria", "description") if k in item}
return f"{MH} item create --type {item_type} --data " + _json({**copy, **extra})

def _duplicates(self, tmp_path: Path, commands: list[str]) -> list[dict]:
report = self._plan_and_run(tmp_path, commands + [self._cr()])
return [w for w in report["warnings"] if w["code"] == "possible_duplicate"]

def test_a_cr_that_reads_like_another_cr_is_not_a_possible_duplicate(self, project: Path, tmp_path: Path) -> None:
assert self._duplicates(tmp_path, [self._copy_of("CR-001", "CR")]) == []

def test_an_item_that_reads_like_a_cancelled_one_is_not_a_possible_duplicate(self, project: Path, tmp_path: Path) -> None:
cancel = f"{MH} item update SYS-001 --data " + _json({"status": "cancelled"})
assert self._duplicates(tmp_path, [cancel, self._copy_of("SYS-001", "SYS")]) == []

def test_a_rejected_cr_needs_no_record(self, project: Path, tmp_path: Path) -> None:
reject = f"{MH} item update CR-001 --data " + _json(
{"status": "rejected", "impact_assessment": "duplicate of CR-002"})
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