diff --git a/desloppify/engine/_state/merge_issues.py b/desloppify/engine/_state/merge_issues.py index 946ffc98f..732ccda10 100644 --- a/desloppify/engine/_state/merge_issues.py +++ b/desloppify/engine/_state/merge_issues.py @@ -97,7 +97,8 @@ def verify_disappeared( Returns (resolved_count, skipped_other_lang, resolved_out_of_scope, changed_detectors). Queue-tracked work stays user-controlled unless the detector is known to have run in the current scan or the source file no longer exists. Manually - resolved items can be marked as scan-verified when they remain absent. + resolved items can be marked as scan-verified when they remain absent + from a detector confirmed to have run in this scan. """ resolved = skipped_other_lang = resolved_out_of_scope = 0 resolved_detectors: set[str] = set() @@ -123,6 +124,16 @@ def verify_disappeared( if previous.get("detector", "unknown") in suspect_detectors: continue + # Review imports and partial scans do not corroborate dispositions for + # detectors that did not run. Suspect detection alone is insufficient: + # it counts open issues, and a detector may have only resolved issues. + # Apply this before scope verification, which also records scan evidence. + if previous_status != "open" and ( + confirmed_detectors is None + or previous.get("detector", "unknown") not in confirmed_detectors + ): + continue + if scan_path and scan_path != ".": prefix = scan_path.rstrip("/") + "/" if ( diff --git a/desloppify/tests/commands/test_queue_count_consistency.py b/desloppify/tests/commands/test_queue_count_consistency.py index 13f6786eb..929a4f623 100644 --- a/desloppify/tests/commands/test_queue_count_consistency.py +++ b/desloppify/tests/commands/test_queue_count_consistency.py @@ -98,6 +98,7 @@ def test_out_of_scope_fixed_issues_get_scan_verification(self): now="2026-03-01T00:00:00+00:00", lang=None, scan_path="src", + confirmed_detectors={"smells"}, ) attestation = existing["f1"]["resolution_attestation"] assert existing["f1"]["status"] == "fixed" @@ -168,6 +169,7 @@ def test_out_of_scope_verification_adds_to_resolved_detectors(self): now="2026-01-01T00:00:00+00:00", lang=None, scan_path="src", + confirmed_detectors={"smells"}, ) assert "smells" in detectors diff --git a/desloppify/tests/review/context/test_mechanical_evidence.py b/desloppify/tests/review/context/test_mechanical_evidence.py index cd1aa1fd6..b2639a474 100644 --- a/desloppify/tests/review/context/test_mechanical_evidence.py +++ b/desloppify/tests/review/context/test_mechanical_evidence.py @@ -622,7 +622,7 @@ def test_auto_resolved_detector_marks_its_dimensions_stale(self): state["work_items"]["structural::big.py::large_file"]["status"] = "fixed" # Second scan: structural issue absent → scan-verified, detector changed - merge_scan(state, [], MergeScanOptions(force_resolve=True)) + merge_scan(state, [], MergeScanOptions(potentials={"structural": 1})) dc = state["subjective_assessments"]["design_coherence"] assert dc["needs_review_refresh"] is True diff --git a/desloppify/tests/review/import_scoring/test_review_mechanical_isolation.py b/desloppify/tests/review/import_scoring/test_review_mechanical_isolation.py new file mode 100644 index 000000000..50cfd88d2 --- /dev/null +++ b/desloppify/tests/review/import_scoring/test_review_mechanical_isolation.py @@ -0,0 +1,38 @@ +"""Review imports leave mechanical dispositions and fresh assessments intact.""" + +from copy import deepcopy + +import pytest + +from desloppify.intelligence.review import import_holistic_issues, import_review_issues +from desloppify.state import ensure_state_defaults +from desloppify.tests.state.test_state import _make_raw_issue + + +@pytest.mark.parametrize("import_review", [import_holistic_issues, import_review_issues]) +@pytest.mark.parametrize("status", ["fixed", "wontfix", "false_positive"]) +def test_review_import_does_not_verify_mechanical_issue( + import_review, status, empty_state, tmp_path +): + issue = _make_raw_issue( + "structural::a.ts::large_file", detector="structural", file="a.ts", status=status, + lang="typescript", + ) + issue["resolution_attestation"] = { + "kind": "manual", "text": "reviewed", "scan_verified": False, + } + empty_state["work_items"][issue["id"]] = issue + ensure_state_defaults(empty_state) + before = deepcopy(issue) + + diff = import_review( + {"issues": [], "assessments": {"design_coherence": 81}}, + empty_state, "typescript", project_root=tmp_path, + ) + + assert diff["auto_resolved"] == 0 + assert issue == before + assessment = empty_state["subjective_assessments"]["design_coherence"] + assert assessment["score"] == 81 + assert not assessment.get("needs_review_refresh") + assert "stale_since" not in assessment diff --git a/desloppify/tests/state/test_manual_scan_verification.py b/desloppify/tests/state/test_manual_scan_verification.py new file mode 100644 index 000000000..80b5be743 --- /dev/null +++ b/desloppify/tests/state/test_manual_scan_verification.py @@ -0,0 +1,66 @@ +"""A review import is not evidence that a mechanical detector ran (#809).""" + +from copy import deepcopy + +import pytest + +from desloppify.engine._state.merge_issues import verify_disappeared +from desloppify.state import MergeScanOptions, empty_state, merge_scan +from desloppify.tests.state.test_state import _make_raw_issue + + +@pytest.mark.parametrize("status", ["fixed", "wontfix", "false_positive"]) +@pytest.mark.parametrize("scan_path", [".", "other"]) +@pytest.mark.parametrize("confirmed_detectors", [None, set(), {"review"}]) +def test_manual_resolution_requires_detector_evidence( + status, scan_path, confirmed_detectors +): + issue = _make_raw_issue( + "smells::src/a.py::item", detector="smells", file="src/a.py", status=status + ) + issue["note"] = "Manual disposition" + issue["resolution_attestation"] = { + "kind": "manual", "text": "reviewed", "scan_verified": False, + } + existing = {issue["id"]: issue} + before = deepcopy(existing) + + result = verify_disappeared( + existing, set(), set(), "2026-03-14T00:00:00+00:00", + lang=None, scan_path=scan_path, confirmed_detectors=confirmed_detectors, + ) + + assert result == (0, 0, 0, set()) + assert existing == before + + +@pytest.mark.parametrize("status", ["fixed", "wontfix", "false_positive"]) +@pytest.mark.parametrize("evidence", ["potentials", "output"]) +def test_manual_resolution_is_verified_when_detector_ran(status, evidence): + state = empty_state() + issue = _make_raw_issue( + "smells::a.py::item", detector="smells", file="a.py", status=status + ) + state["work_items"][issue["id"]] = issue + output = [] + options = MergeScanOptions(potentials={"smells": 10}) + if evidence == "output": + output = [_make_raw_issue("smells::b.py::item", detector="smells", file="b.py")] + options = MergeScanOptions() + + diff = merge_scan(state, output, options) + + assert diff["auto_resolved"] == 1 + assert issue["status"] == status + assert issue["resolution_attestation"]["scan_verified"] is True + + +def test_force_resolve_does_not_invent_detector_evidence(): + state = empty_state() + issue = _make_raw_issue("smells::a.py::item", detector="smells", status="fixed") + state["work_items"][issue["id"]] = issue + + diff = merge_scan(state, [], MergeScanOptions(force_resolve=True, potentials={"review": 1})) + + assert diff["auto_resolved"] == 0 + assert "resolution_attestation" not in issue diff --git a/desloppify/tests/state/test_state.py b/desloppify/tests/state/test_state.py index 188a1fa68..86a89d078 100644 --- a/desloppify/tests/state/test_state.py +++ b/desloppify/tests/state/test_state.py @@ -665,7 +665,7 @@ def test_missing_fixed_issue_gets_scan_verified(self): } st["issues"]["det::a.py::fn"] = old - diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True)) + diff = merge_scan(st, [], MergeScanOptions(lang="python", potentials={"det": 1})) assert diff["auto_resolved"] == 1 assert st["issues"]["det::a.py::fn"]["status"] == "fixed" assert st["issues"]["det::a.py::fn"]["resolution_attestation"]["scan_verified"] is True