`" in prompt
diff --git a/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py b/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py
index f2f98924..b79fcba9 100644
--- a/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py
+++ b/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from desloppify.engine._plan.triage.core import TriageResult, apply_triage_to_plan
-from desloppify.engine._plan.reconcile import reconcile_plan_after_scan
+from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan
from desloppify.engine._plan.schema import empty_plan, ensure_plan_defaults, triage_clusters
diff --git a/desloppify/tests/plan/test_persistence_runtime_paths.py b/desloppify/tests/plan/test_persistence_runtime_paths.py
index 99a0d926..33729363 100644
--- a/desloppify/tests/plan/test_persistence_runtime_paths.py
+++ b/desloppify/tests/plan/test_persistence_runtime_paths.py
@@ -32,3 +32,35 @@ def test_plan_persistence_honors_monkeypatched_plan_file(monkeypatch, tmp_path):
assert custom_plan_file.exists()
assert loaded["queue_order"] == ["review::b.py::issue-2"]
+
+
+def test_resolve_plan_load_status_marks_backup_recovery_degraded(tmp_path, capsys):
+ plan_file = tmp_path / "plan.json"
+ backup_file = tmp_path / "plan.json.bak"
+ plan_file.write_text("{not json", encoding="utf-8")
+ backup_file.write_text(
+ '{"version": 8, "created": "2026-01-01T00:00:00+00:00", "updated": "2026-01-01T00:00:00+00:00", "queue_order": ["review::a.py::issue-1"], "deferred": [], "skipped": {}, "active_cluster": null, "overrides": {}, "clusters": {}, "superseded": {}, "promoted_ids": [], "plan_start_scores": {}, "refresh_state": {}, "execution_log": [], "epic_triage_meta": {}, "commit_log": [], "uncommitted_issues": [], "commit_tracking_branch": null}\n',
+ encoding="utf-8",
+ )
+
+ status = persistence_mod.resolve_plan_load_status(plan_file)
+
+ assert status.degraded is True
+ assert status.recovery == "backup"
+ assert status.error_kind == "JSONDecodeError"
+ assert status.plan is not None
+ assert status.plan["queue_order"] == ["review::a.py::issue-1"]
+ assert "recovered from backup" in capsys.readouterr().err
+
+
+def test_resolve_plan_load_status_marks_fresh_start_when_recovery_fails(tmp_path, capsys):
+ plan_file = tmp_path / "plan.json"
+ plan_file.write_text("{not json", encoding="utf-8")
+
+ status = persistence_mod.resolve_plan_load_status(plan_file)
+
+ assert status.degraded is True
+ assert status.recovery == "fresh_start"
+ assert status.error_kind == "JSONDecodeError"
+ assert status.plan == empty_plan()
+ assert "starting fresh" in capsys.readouterr().err.lower()
diff --git a/desloppify/tests/plan/test_phase_cleanup.py b/desloppify/tests/plan/test_phase_cleanup.py
new file mode 100644
index 00000000..e163affb
--- /dev/null
+++ b/desloppify/tests/plan/test_phase_cleanup.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from desloppify.engine._plan.refresh_lifecycle import (
+ LIFECYCLE_PHASE_EXECUTE,
+ LIFECYCLE_PHASE_REVIEW_POSTFLIGHT,
+ LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT,
+)
+from desloppify.engine._plan.schema import empty_plan
+from desloppify.engine._plan.sync.phase_cleanup import prune_synthetic_for_phase
+
+
+def test_workflow_cleanup_prunes_only_subjective_items() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ "triage::observe",
+ "unused::src/a.ts::x",
+ ]
+ plan["overrides"] = {
+ "subjective::naming_quality": {"issue_id": "subjective::naming_quality"},
+ "workflow::communicate-score": {"issue_id": "workflow::communicate-score"},
+ }
+ plan["clusters"] = {
+ "mixed": {
+ "name": "mixed",
+ "issue_ids": [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ "unused::src/a.ts::x",
+ ],
+ }
+ }
+
+ pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT)
+
+ assert pruned == ["subjective::naming_quality"]
+ assert plan["queue_order"] == [
+ "workflow::communicate-score",
+ "triage::observe",
+ "unused::src/a.ts::x",
+ ]
+ assert "subjective::naming_quality" not in plan["overrides"]
+ assert plan["clusters"]["mixed"]["issue_ids"] == [
+ "workflow::communicate-score",
+ "unused::src/a.ts::x",
+ ]
+
+
+def test_review_postflight_cleanup_prunes_subjective_and_workflow() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ "review::src/a.ts::naming",
+ ]
+
+ pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_REVIEW_POSTFLIGHT)
+
+ assert pruned == [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ ]
+ assert plan["queue_order"] == ["review::src/a.ts::naming"]
+
+
+def test_execute_cleanup_prunes_all_synthetic_prefixes() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ "triage::observe",
+ "unused::src/a.ts::x",
+ ]
+
+ pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_EXECUTE)
+
+ assert pruned == [
+ "subjective::naming_quality",
+ "workflow::communicate-score",
+ "triage::observe",
+ ]
+ assert plan["queue_order"] == ["unused::src/a.ts::x"]
diff --git a/desloppify/tests/plan/test_planned_objective_ids.py b/desloppify/tests/plan/test_planned_objective_ids.py
deleted file mode 100644
index cb5a0746..00000000
--- a/desloppify/tests/plan/test_planned_objective_ids.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Tests for plan-tracked objective ID selection."""
-
-from __future__ import annotations
-
-from desloppify.engine._plan.schema import planned_objective_ids
-
-
-def test_planned_objective_ids_returns_all_when_plan_tracks_nothing() -> None:
- all_ids = {"issue-1", "issue-2"}
-
- assert planned_objective_ids(all_ids, {"queue_order": [], "clusters": {}}) == all_ids
-
-
-def test_planned_objective_ids_returns_overlap_when_live_tracked_ids_exist() -> None:
- all_ids = {"issue-1", "issue-2", "issue-3"}
- plan = {
- "queue_order": ["issue-2"],
- "clusters": {"c1": {"issue_ids": ["issue-3"], "action_steps": []}},
- "skipped": {},
- "overrides": {},
- }
-
- assert planned_objective_ids(all_ids, plan) == {"issue-2", "issue-3"}
-
-
-def test_planned_objective_ids_returns_empty_when_tracked_ids_are_stale() -> None:
- all_ids = {"issue-1", "issue-2"}
- plan = {
- "queue_order": ["missing-issue"],
- "clusters": {"c1": {"issue_ids": ["missing-cluster-issue"], "action_steps": []}},
- "skipped": {},
- "overrides": {},
- }
-
- assert planned_objective_ids(all_ids, plan) == set()
-
-
-def test_planned_objective_ids_ignores_synthetic_and_skipped_tracking() -> None:
- all_ids = {"issue-1", "issue-2"}
- plan = {
- "queue_order": ["workflow::create-plan"],
- "clusters": {},
- "skipped": {"issue-1": {"kind": "temporary"}},
- "overrides": {},
- }
-
- assert planned_objective_ids(all_ids, plan) == all_ids
diff --git a/desloppify/tests/plan/test_reconcile.py b/desloppify/tests/plan/test_reconcile.py
index 4e7d4b28..c2b0a695 100644
--- a/desloppify/tests/plan/test_reconcile.py
+++ b/desloppify/tests/plan/test_reconcile.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from desloppify.engine._plan.operations.cluster import add_to_cluster, create_cluster
-from desloppify.engine._plan.reconcile import reconcile_plan_after_scan
+from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan
from desloppify.engine._plan.schema import empty_plan, ensure_plan_defaults
# ---------------------------------------------------------------------------
@@ -144,3 +144,24 @@ def test_reconcile_no_log_when_no_changes():
log = plan.get("execution_log", [])
reconcile_entries = [e for e in log if e["action"] == "reconcile"]
assert len(reconcile_entries) == 0
+
+
+def test_reconcile_prunes_existing_superseded_references():
+ """Already-superseded IDs should not linger in queue_order or clusters."""
+ plan = _plan_with_queue("a", "b")
+ ensure_plan_defaults(plan)
+ plan["superseded"]["a"] = {
+ "original_id": "a",
+ "status": "superseded",
+ "superseded_at": "2026-01-01T00:00:00+00:00",
+ }
+ plan["promoted_ids"] = ["a"]
+ create_cluster(plan, "my-cluster")
+ add_to_cluster(plan, "my-cluster", ["a", "b"])
+
+ result = reconcile_plan_after_scan(plan, _state_with_issues("b"))
+
+ assert result.changes > 0
+ assert "a" not in plan["queue_order"]
+ assert "a" not in plan["promoted_ids"]
+ assert "a" not in plan["clusters"]["my-cluster"]["issue_ids"]
diff --git a/desloppify/tests/plan/test_reconcile_pipeline.py b/desloppify/tests/plan/test_reconcile_pipeline.py
new file mode 100644
index 00000000..db37507b
--- /dev/null
+++ b/desloppify/tests/plan/test_reconcile_pipeline.py
@@ -0,0 +1,578 @@
+"""Direct tests for the shared reconcile pipeline and queue ownership rules.
+
+Covers the gate matrix from centralize-postflight-pipeline.md:
+- Boundary detection (fresh, mid-cycle, queue-clear)
+- Phase isolation (promoted vs unpromoted clusters)
+- Phantom resurrection / stuck queue guards
+- Second reconcile is no-op (idempotency)
+- Sentinel helper encapsulation
+- workflow_injected_ids aggregation
+"""
+
+from __future__ import annotations
+
+from desloppify.engine._plan.auto_cluster import auto_cluster_issues
+from desloppify.engine._plan.constants import (
+ WORKFLOW_COMMUNICATE_SCORE_ID,
+ WORKFLOW_CREATE_PLAN_ID,
+)
+from desloppify.engine._plan.schema import empty_plan
+from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan
+from desloppify.engine._plan.sync.workflow import clear_score_communicated_sentinel
+from desloppify.engine._work_queue.snapshot import (
+ PHASE_ASSESSMENT_POSTFLIGHT,
+ PHASE_EXECUTE,
+ PHASE_REVIEW_POSTFLIGHT,
+ PHASE_TRIAGE_POSTFLIGHT,
+ PHASE_WORKFLOW_POSTFLIGHT,
+ PHASE_SCAN,
+ build_queue_snapshot,
+)
+
+
+def _issue(issue_id: str, detector: str = "unused") -> dict:
+ return {
+ "id": issue_id,
+ "detector": detector,
+ "status": "open",
+ "file": "src/app.py",
+ "tier": 1,
+ "confidence": "high",
+ "summary": issue_id,
+ "detail": {},
+ }
+
+
+# ---------------------------------------------------------------------------
+# Boundary detection
+# ---------------------------------------------------------------------------
+
+
+def test_live_planned_queue_empty_uses_queue_order_only() -> None:
+ """Overrides/clusters in plan do NOT expand the live queue."""
+ plan = empty_plan()
+ plan["clusters"] = {
+ "manual/review": {
+ "name": "manual/review",
+ "issue_ids": ["unused::a"],
+ "execution_status": "active",
+ }
+ }
+ plan["overrides"] = {
+ "unused::a": {
+ "issue_id": "unused::a",
+ "cluster": "manual/review",
+ }
+ }
+
+ assert live_planned_queue_empty(plan) is True
+
+
+def test_live_planned_queue_not_empty_with_substantive_item() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::a"]
+
+ assert live_planned_queue_empty(plan) is False
+
+
+def test_live_planned_queue_empty_ignores_synthetic_items() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = [
+ "workflow::communicate-score",
+ "subjective::naming",
+ "triage::stage-1",
+ ]
+
+ assert live_planned_queue_empty(plan) is True
+
+
+def test_live_planned_queue_empty_ignores_skipped_items() -> None:
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::a"]
+ plan["skipped"] = {"unused::a": {"reason": "manual"}}
+
+ assert live_planned_queue_empty(plan) is True
+
+
+def test_reconcile_plan_noops_when_live_queue_not_empty() -> None:
+ """Mid-cycle: pipeline is a no-op, no gates fire."""
+ state = {"issues": {"unused::a": _issue("unused::a")}}
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::a"]
+ plan["plan_start_scores"] = {"strict": 80.0}
+
+ result = reconcile_plan(plan, state, target_strict=95.0)
+
+ assert result.dirty is False
+ assert result.workflow_injected_ids == []
+ assert plan["queue_order"] == ["unused::a"]
+
+
+def test_reconcile_plan_second_call_is_noop() -> None:
+ """Idempotency: calling reconcile_plan twice at the same boundary
+ produces no additional mutations."""
+ state = {"issues": {}}
+ plan = empty_plan()
+
+ reconcile_plan(plan, state, target_strict=95.0)
+ queue_after_first = list(plan.get("queue_order", []))
+ log_after_first = list(plan.get("log", []))
+
+ result2 = reconcile_plan(plan, state, target_strict=95.0)
+
+ assert plan.get("queue_order", []) == queue_after_first
+ # Log may have lifecycle entries but should not grow on second call
+ assert len(plan.get("log", [])) == len(log_after_first)
+ # Second result should show no new dirty changes beyond lifecycle
+ # (lifecycle is always computed but should match, so not changed)
+ assert result2.auto_cluster_changes == 0
+ assert result2.workflow_injected_ids == []
+
+
+def test_reconcile_plan_holds_workflow_until_current_scan_subjective_review_completes() -> None:
+ """Postflight review must run before communicate-score/create-plan."""
+ state = {
+ "issues": {"unused::a": _issue("unused::a")},
+ "scan_count": 19,
+ "dimension_scores": {
+ "Naming quality": {
+ "score": 82.0,
+ "strict": 82.0,
+ "failing": 0,
+ "checks": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "naming_quality"},
+ },
+ }
+ },
+ "subjective_assessments": {
+ "naming_quality": {"score": 82.0, "placeholder": False}
+ },
+ }
+ plan = empty_plan()
+ plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 19}
+
+ result = reconcile_plan(plan, state, target_strict=95.0)
+
+ assert "subjective::naming_quality" in plan["queue_order"]
+ assert WORKFLOW_COMMUNICATE_SCORE_ID not in plan["queue_order"]
+ assert WORKFLOW_CREATE_PLAN_ID not in plan["queue_order"]
+ assert result.workflow_injected_ids == []
+
+ plan["queue_order"] = [
+ issue_id
+ for issue_id in plan["queue_order"]
+ if issue_id != "subjective::naming_quality"
+ ]
+ plan["refresh_state"]["subjective_review_completed_at_scan_count"] = 19
+
+ result = reconcile_plan(plan, state, target_strict=95.0)
+
+ assert WORKFLOW_COMMUNICATE_SCORE_ID in plan["queue_order"]
+ assert WORKFLOW_CREATE_PLAN_ID in plan["queue_order"]
+ assert result.workflow_injected_ids == [
+ WORKFLOW_COMMUNICATE_SCORE_ID,
+ WORKFLOW_CREATE_PLAN_ID,
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Mid-cycle auto-clustering guard
+# ---------------------------------------------------------------------------
+
+
+def test_auto_cluster_issues_is_noop_mid_cycle() -> None:
+ state = {
+ "issues": {
+ "unused::a": _issue("unused::a"),
+ "unused::b": _issue("unused::b"),
+ }
+ }
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::a"]
+ plan["plan_start_scores"] = {"strict": 80.0}
+
+ changes = auto_cluster_issues(plan, state)
+
+ assert changes == 0
+ assert plan["clusters"] == {}
+
+
+# ---------------------------------------------------------------------------
+# Phase isolation: promoted vs unpromoted clusters
+# ---------------------------------------------------------------------------
+
+
+def test_queue_snapshot_executes_review_items_promoted_into_active_cluster() -> None:
+ """Active cluster with items in queue_order → EXECUTE phase."""
+ state = {
+ "issues": {
+ "review::a": _issue("review::a", detector="review"),
+ }
+ }
+ plan = empty_plan()
+ plan["queue_order"] = ["review::a"]
+ plan["plan_start_scores"] = {"strict": 80.0}
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::a"],
+ "issue_snapshot_hash": "stable",
+ }
+ plan["clusters"] = {
+ "epic/review": {
+ "name": "epic/review",
+ "issue_ids": ["review::a"],
+ "execution_status": "active",
+ }
+ }
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert snapshot.phase == PHASE_EXECUTE
+ assert [item["id"] for item in snapshot.execution_items] == ["review::a"]
+
+
+def test_queue_snapshot_keeps_unpromoted_review_cluster_in_postflight() -> None:
+ """Review cluster (execution_status: review) → postflight, not execute."""
+ state = {
+ "issues": {
+ "review::a": _issue("review::a", detector="review"),
+ }
+ }
+ plan = empty_plan()
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::a"],
+ "issue_snapshot_hash": "stable",
+ }
+ plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1}
+ plan["clusters"] = {
+ "manual/review": {
+ "name": "manual/review",
+ "issue_ids": ["review::a"],
+ "execution_status": "review",
+ }
+ }
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert live_planned_queue_empty(plan) is True
+ assert snapshot.phase == PHASE_REVIEW_POSTFLIGHT
+ assert [item["id"] for item in snapshot.execution_items] == ["review::a"]
+
+
+def test_phase_isolation_mixed_objective_and_unpromoted_review() -> None:
+ """Objective work in queue + unpromoted review findings → only objective
+ items in execution, review stays postflight."""
+ state = {
+ "issues": {
+ "unused::obj": _issue("unused::obj"),
+ "review::rev": _issue("review::rev", detector="review"),
+ }
+ }
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::obj"]
+ plan["plan_start_scores"] = {"strict": 80.0}
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::rev"],
+ "issue_snapshot_hash": "stable",
+ }
+ plan["clusters"] = {
+ "manual/review": {
+ "name": "manual/review",
+ "issue_ids": ["review::rev"],
+ "execution_status": "review",
+ }
+ }
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert snapshot.phase == PHASE_EXECUTE
+ execution_ids = [item["id"] for item in snapshot.execution_items]
+ assert "unused::obj" in execution_ids
+ assert "review::rev" not in execution_ids
+
+
+def test_postflight_phase_stays_exclusive_when_new_execute_items_exist() -> None:
+ """Fresh execute work discovered during postflight stays backlog-only until postflight ends."""
+ state = {
+ "issues": {
+ "unused::obj": _issue("unused::obj"),
+ },
+ "dimension_scores": {
+ "Naming quality": {
+ "score": 70.0,
+ "strict": 70.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "naming_quality"},
+ },
+ },
+ },
+ "subjective_assessments": {
+ "naming_quality": {"score": 70.0, "needs_review_refresh": True},
+ },
+ }
+ plan = empty_plan()
+ plan["queue_order"] = [
+ "unused::obj",
+ "workflow::communicate-score",
+ "triage::observe",
+ ]
+ plan["refresh_state"] = {
+ "postflight_scan_completed_at_scan_count": 5,
+ "lifecycle_phase": "review",
+ }
+ plan["plan_start_scores"] = {"strict": 70.0, "overall": 70.0}
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert snapshot.phase == PHASE_ASSESSMENT_POSTFLIGHT
+ assert [item["id"] for item in snapshot.execution_items] == ["subjective::naming_quality"]
+ assert "unused::obj" in [item["id"] for item in snapshot.backlog_items]
+
+
+def test_postflight_execute_items_reappear_after_postflight_drains() -> None:
+ """Once postflight items are done, queued execute work becomes live again."""
+ state = {
+ "issues": {
+ "unused::obj": _issue("unused::obj"),
+ },
+ }
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::obj"]
+ plan["refresh_state"] = {
+ "postflight_scan_completed_at_scan_count": 5,
+ "lifecycle_phase": "workflow",
+ }
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert snapshot.phase == PHASE_EXECUTE
+ assert [item["id"] for item in snapshot.execution_items] == ["unused::obj"]
+
+
+def test_sticky_postflight_advances_through_remaining_subphases() -> None:
+ """Sticky postflight respects the fixed ordered sequence while active."""
+ state = {"issues": {}}
+
+ workflow_plan = empty_plan()
+ workflow_plan["queue_order"] = ["workflow::communicate-score", "triage::observe"]
+ workflow_plan["refresh_state"] = {
+ "postflight_scan_completed_at_scan_count": 5,
+ "lifecycle_phase": "workflow",
+ }
+ workflow_snapshot = build_queue_snapshot(state, plan=workflow_plan)
+ assert workflow_snapshot.phase == PHASE_WORKFLOW_POSTFLIGHT
+
+ triage_plan = empty_plan()
+ triage_plan["queue_order"] = ["triage::observe"]
+ triage_plan["refresh_state"] = {
+ "postflight_scan_completed_at_scan_count": 5,
+ "lifecycle_phase": "triage",
+ }
+ triage_snapshot = build_queue_snapshot(state, plan=triage_plan)
+ assert triage_snapshot.phase == PHASE_TRIAGE_POSTFLIGHT
+
+
+# ---------------------------------------------------------------------------
+# Phantom resurrection guard
+# ---------------------------------------------------------------------------
+
+
+def test_phantom_resurrection_guard_overrides_not_in_queue() -> None:
+ """Items in overrides/clusters but NOT in queue_order must NOT be treated
+ as live queue work."""
+ plan = empty_plan()
+ plan["queue_order"] = [] # explicitly empty
+ plan["overrides"] = {
+ "unused::ghost": {
+ "issue_id": "unused::ghost",
+ "cluster": "manual/ghost",
+ }
+ }
+ plan["clusters"] = {
+ "manual/ghost": {
+ "name": "manual/ghost",
+ "issue_ids": ["unused::ghost"],
+ "execution_status": "active",
+ }
+ }
+
+ assert live_planned_queue_empty(plan) is True
+
+ state = {"issues": {"unused::ghost": _issue("unused::ghost")}}
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ # Phase should NOT be EXECUTE — queue is empty
+ assert snapshot.phase != PHASE_EXECUTE or not any(
+ item["id"] == "unused::ghost" for item in snapshot.execution_items
+ if not item.get("kind") == "cluster"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Stuck queue guard
+# ---------------------------------------------------------------------------
+
+
+def test_stuck_queue_guard_removed_item_stays_gone() -> None:
+ """Item removed from queue_order but still in overrides/clusters is NOT
+ resurrected into the live queue."""
+ plan = empty_plan()
+ plan["queue_order"] = ["unused::kept"]
+ plan["overrides"] = {
+ "unused::removed": {
+ "issue_id": "unused::removed",
+ "cluster": "manual/old",
+ }
+ }
+ plan["clusters"] = {
+ "manual/old": {
+ "name": "manual/old",
+ "issue_ids": ["unused::removed"],
+ "execution_status": "active",
+ }
+ }
+
+ assert live_planned_queue_empty(plan) is False
+
+ state = {
+ "issues": {
+ "unused::kept": _issue("unused::kept"),
+ "unused::removed": _issue("unused::removed"),
+ }
+ }
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ execution_ids = {item["id"] for item in snapshot.execution_items}
+ assert "unused::kept" in execution_ids
+ # The removed item should not reappear in execution
+ assert "unused::removed" not in execution_ids
+
+
+# ---------------------------------------------------------------------------
+# Sentinel helper
+# ---------------------------------------------------------------------------
+
+
+def test_clear_score_communicated_sentinel() -> None:
+ """Helper removes the sentinel key; missing key is a no-op."""
+ plan = empty_plan()
+ plan["previous_plan_start_scores"] = {"strict": 80.0}
+
+ clear_score_communicated_sentinel(plan)
+ assert "previous_plan_start_scores" not in plan
+
+ # Second call is a no-op (no KeyError)
+ clear_score_communicated_sentinel(plan)
+ assert "previous_plan_start_scores" not in plan
+
+
+def test_sentinel_blocks_communicate_score_reinjection() -> None:
+ """When the sentinel is set, communicate-score does not re-inject."""
+ from desloppify.engine._plan.sync.workflow import sync_communicate_score_needed
+
+ plan = empty_plan()
+ plan["previous_plan_start_scores"] = {"strict": 80.0}
+ state: dict = {"issues": {}}
+
+ result = sync_communicate_score_needed(plan, state)
+ assert not result.changes
+
+ # After clearing sentinel, gate may fire
+ clear_score_communicated_sentinel(plan)
+ assert "previous_plan_start_scores" not in plan
+
+
+# ---------------------------------------------------------------------------
+# workflow_injected_ids aggregation
+# ---------------------------------------------------------------------------
+
+
+def test_workflow_injected_ids_aggregates_both_gates() -> None:
+ from desloppify.engine._plan.constants import QueueSyncResult
+ from desloppify.engine._plan.sync.pipeline import ReconcileResult
+
+ result = ReconcileResult(
+ communicate_score=QueueSyncResult(
+ injected=[WORKFLOW_COMMUNICATE_SCORE_ID],
+ ),
+ create_plan=QueueSyncResult(
+ injected=[WORKFLOW_CREATE_PLAN_ID],
+ ),
+ )
+
+ ids = result.workflow_injected_ids
+ assert WORKFLOW_COMMUNICATE_SCORE_ID in ids
+ assert WORKFLOW_CREATE_PLAN_ID in ids
+ assert len(ids) == 2
+
+
+def test_workflow_injected_ids_empty_when_no_gates_fire() -> None:
+ from desloppify.engine._plan.sync.pipeline import ReconcileResult
+
+ result = ReconcileResult()
+ assert result.workflow_injected_ids == []
+
+
+# ---------------------------------------------------------------------------
+# Lifecycle does not persist when phase unchanged
+# ---------------------------------------------------------------------------
+
+
+def test_lifecycle_does_not_persist_when_unchanged() -> None:
+ """If the resolved phase matches the current lifecycle_phase value,
+ lifecycle_phase_changed should be False."""
+ state = {"issues": {}}
+ plan = empty_plan()
+
+ # First call sets lifecycle
+ result1 = reconcile_plan(plan, state, target_strict=95.0)
+ assert result1.lifecycle_phase_changed is True
+
+ # Second call at same boundary — phase unchanged
+ result2 = reconcile_plan(plan, state, target_strict=95.0)
+ assert result2.lifecycle_phase == result1.lifecycle_phase
+ assert result2.lifecycle_phase_changed is False
+
+
+# ---------------------------------------------------------------------------
+# Pipeline does not touch scan-specific state
+# ---------------------------------------------------------------------------
+
+
+def test_pipeline_does_not_seed_plan_start_scores() -> None:
+ """reconcile_plan never writes plan_start_scores — that's scan-specific."""
+ state = {"issues": {}}
+ plan = empty_plan()
+ assert not plan.get("plan_start_scores")
+
+ reconcile_plan(plan, state, target_strict=95.0)
+
+ # Should still be empty/falsy — pipeline doesn't seed it
+ assert not plan.get("plan_start_scores")
+
+
+def test_pipeline_does_not_mark_postflight_scan_complete() -> None:
+ """reconcile_plan never sets postflight_scan_completed_at_scan_count."""
+ state = {"issues": {}}
+ plan = empty_plan()
+
+ reconcile_plan(plan, state, target_strict=95.0)
+
+ refresh = plan.get("refresh_state", {})
+ assert "postflight_scan_completed_at_scan_count" not in refresh
+
+
+# ---------------------------------------------------------------------------
+# Fresh boundary behavior
+# ---------------------------------------------------------------------------
+
+
+def test_fresh_boundary_empty_state_resolves_scan_phase() -> None:
+ """Empty state + no plan_start_scores → fresh boundary → scan phase."""
+ state = {"issues": {}}
+ plan = empty_plan()
+
+ snapshot = build_queue_snapshot(state, plan=plan)
+
+ assert snapshot.phase == PHASE_SCAN
diff --git a/desloppify/tests/plan/test_refresh_lifecycle.py b/desloppify/tests/plan/test_refresh_lifecycle.py
index 0d33efee..3be8d289 100644
--- a/desloppify/tests/plan/test_refresh_lifecycle.py
+++ b/desloppify/tests/plan/test_refresh_lifecycle.py
@@ -1,16 +1,15 @@
from __future__ import annotations
from desloppify.engine._plan.refresh_lifecycle import (
+ coarse_lifecycle_phase,
clear_postflight_scan_completion,
current_lifecycle_phase,
LIFECYCLE_PHASE_EXECUTE,
LIFECYCLE_PHASE_REVIEW,
+ LIFECYCLE_PHASE_REVIEW_POSTFLIGHT,
LIFECYCLE_PHASE_SCAN,
- LIFECYCLE_PHASE_TRIAGE,
- LIFECYCLE_PHASE_WORKFLOW,
mark_postflight_scan_completed,
postflight_scan_pending,
- sync_lifecycle_phase,
)
from desloppify.engine._plan.schema import empty_plan
@@ -47,6 +46,20 @@ def test_clearing_completion_for_real_issue_requires_new_scan() -> None:
changed = clear_postflight_scan_completion(
plan,
issue_ids=["unused::src/app.ts::thing"],
+ state={
+ "issues": {
+ "unused::src/app.ts::thing": {
+ "id": "unused::src/app.ts::thing",
+ "detector": "unused",
+ "status": "open",
+ "file": "src/app.ts",
+ "tier": 1,
+ "confidence": "high",
+ "summary": "unused import",
+ "detail": {},
+ }
+ }
+ },
)
assert changed is True
@@ -54,6 +67,33 @@ def test_clearing_completion_for_real_issue_requires_new_scan() -> None:
assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_EXECUTE
+def test_clearing_completion_for_review_issue_keeps_current_scan_boundary() -> None:
+ plan = empty_plan()
+ mark_postflight_scan_completed(plan, scan_count=5)
+
+ changed = clear_postflight_scan_completion(
+ plan,
+ issue_ids=["review::src/app.ts::naming"],
+ state={
+ "issues": {
+ "review::src/app.ts::naming": {
+ "id": "review::src/app.ts::naming",
+ "detector": "review",
+ "status": "open",
+ "file": "src/app.ts",
+ "tier": 1,
+ "confidence": "high",
+ "summary": "naming issue",
+ "detail": {"dimension": "naming_quality"},
+ }
+ }
+ },
+ )
+
+ assert changed is False
+ assert postflight_scan_pending(plan) is False
+
+
def test_current_lifecycle_phase_falls_back_for_legacy_plans() -> None:
plan = empty_plan()
assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_SCAN
@@ -63,68 +103,8 @@ def test_current_lifecycle_phase_falls_back_for_legacy_plans() -> None:
assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_EXECUTE
-def test_sync_lifecycle_phase_persists_explicit_phase_order() -> None:
+def test_coarse_lifecycle_phase_maps_fine_phases() -> None:
plan = empty_plan()
+ plan["refresh_state"] = {"lifecycle_phase": LIFECYCLE_PHASE_REVIEW_POSTFLIGHT}
- phase, changed = sync_lifecycle_phase(
- plan,
- has_initial_reviews=True,
- has_objective_backlog=False,
- has_postflight_review=False,
- has_postflight_workflow=False,
- has_triage=False,
- has_deferred=False,
- )
- assert changed is True
- assert phase == LIFECYCLE_PHASE_REVIEW
- assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_REVIEW
-
- mark_postflight_scan_completed(plan, scan_count=5)
- phase, changed = sync_lifecycle_phase(
- plan,
- has_initial_reviews=False,
- has_objective_backlog=False,
- has_postflight_review=False,
- has_postflight_workflow=True,
- has_triage=False,
- has_deferred=False,
- )
- assert changed is True
- assert phase == LIFECYCLE_PHASE_WORKFLOW
-
- phase, changed = sync_lifecycle_phase(
- plan,
- has_initial_reviews=False,
- has_objective_backlog=False,
- has_postflight_review=False,
- has_postflight_workflow=False,
- has_triage=True,
- has_deferred=False,
- )
- assert changed is True
- assert phase == LIFECYCLE_PHASE_TRIAGE
-
- phase, changed = sync_lifecycle_phase(
- plan,
- has_initial_reviews=False,
- has_objective_backlog=True,
- has_postflight_review=False,
- has_postflight_workflow=False,
- has_triage=False,
- has_deferred=False,
- )
- assert changed is True
- assert phase == LIFECYCLE_PHASE_EXECUTE
-
- plan["refresh_state"].pop("postflight_scan_completed_at_scan_count", None)
- phase, changed = sync_lifecycle_phase(
- plan,
- has_initial_reviews=False,
- has_objective_backlog=False,
- has_postflight_review=False,
- has_postflight_workflow=False,
- has_triage=False,
- has_deferred=False,
- )
- assert changed is True
- assert phase == LIFECYCLE_PHASE_SCAN
+ assert coarse_lifecycle_phase(plan) == LIFECYCLE_PHASE_REVIEW
diff --git a/desloppify/tests/plan/test_skip.py b/desloppify/tests/plan/test_skip.py
index b4926b4a..f62e9ca8 100644
--- a/desloppify/tests/plan/test_skip.py
+++ b/desloppify/tests/plan/test_skip.py
@@ -14,7 +14,7 @@
skip_items,
unskip_items,
)
-from desloppify.engine._plan.reconcile import reconcile_plan_after_scan
+from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan
from desloppify.engine._plan.schema import (
empty_plan,
ensure_plan_defaults,
diff --git a/desloppify/tests/plan/test_stale_dimensions.py b/desloppify/tests/plan/test_stale_dimensions.py
index 679f8469..0bbcb0a5 100644
--- a/desloppify/tests/plan/test_stale_dimensions.py
+++ b/desloppify/tests/plan/test_stale_dimensions.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.engine._plan.reconcile import reconcile_plan_after_scan
+from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan
from desloppify.engine._plan.schema import empty_plan
from desloppify.engine._plan.sync.dimensions import sync_subjective_dimensions
@@ -39,8 +39,10 @@ def _state_with_stale_dimensions(*dim_keys: str, score: float = 50.0) -> dict:
"refresh_reason": "mechanical_issues_changed",
"stale_since": "2025-01-01T00:00:00+00:00",
}
+ work_items: dict[str, dict] = {}
return {
- "issues": {},
+ "work_items": work_items,
+ "issues": work_items,
"scan_count": 5,
"dimension_scores": dim_scores,
"subjective_assessments": assessments,
@@ -69,8 +71,10 @@ def _state_with_unscored_dimensions(*dim_keys: str) -> dict:
"source": "scan_reset_subjective",
"placeholder": True,
}
+ work_items: dict[str, dict] = {}
return {
- "issues": {},
+ "work_items": work_items,
+ "issues": work_items,
"scan_count": 1,
"dimension_scores": dim_scores,
"subjective_assessments": assessments,
@@ -98,8 +102,10 @@ def _state_with_under_target_dimensions(*dim_keys: str, score: float = 50.0) ->
"score": score,
"needs_review_refresh": False,
}
+ work_items: dict[str, dict] = {}
return {
- "issues": {},
+ "work_items": work_items,
+ "issues": work_items,
"scan_count": 5,
"dimension_scores": dim_scores,
"subjective_assessments": assessments,
@@ -206,7 +212,8 @@ def test_unscored_sync_does_not_prune_stale_ids():
def test_unscored_no_injection_when_no_dimension_scores():
plan = _plan_with_queue()
- state = {"issues": {}, "scan_count": 1}
+ work_items: dict[str, dict] = {}
+ state = {"work_items": work_items, "issues": work_items, "scan_count": 1}
result = sync_subjective_dimensions(plan, state)
assert result.injected == []
@@ -267,7 +274,7 @@ def test_no_injection_when_queue_has_real_items():
plan = _plan_with_queue("some_issue::file.py::abc123")
state = _state_with_stale_dimensions("design_coherence")
# Add an actual open objective issue to state (source of truth)
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -286,7 +293,7 @@ def test_stale_ids_evicted_when_objective_backlog_exists():
"some_issue::file.py::abc123",
)
state = _state_with_stale_dimensions("design_coherence", "error_consistency")
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -306,7 +313,7 @@ def test_stale_ids_inject_when_backlog_clears():
"""Stale IDs inject when objective backlog clears."""
plan = _plan_with_queue("some_issue::file.py::abc123")
state = _state_with_stale_dimensions("design_coherence")
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -317,7 +324,7 @@ def test_stale_ids_inject_when_backlog_clears():
assert r1.injected == []
# Objective backlog clears
- state["issues"]["some_issue::file.py::abc123"]["status"] = "done"
+ state["work_items"]["some_issue::file.py::abc123"]["status"] = "done"
r2 = sync_subjective_dimensions(plan, state)
assert "subjective::design_coherence" in r2.injected
@@ -579,7 +586,7 @@ def test_under_target_evicted_mid_cycle_with_objective_backlog():
stale=[],
under_target=["naming_quality"],
)
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -602,7 +609,7 @@ def test_under_target_reinjected_after_objective_backlog_clears():
stale=[],
under_target=["naming_quality", "error_handling"],
)
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -614,7 +621,7 @@ def test_under_target_reinjected_after_objective_backlog_clears():
assert "subjective::naming_quality" not in plan["queue_order"]
# Step 2: objective backlog clears
- state["issues"]["some_issue::file.py::abc123"]["status"] = "done"
+ state["work_items"]["some_issue::file.py::abc123"]["status"] = "done"
# Step 3: under_target IDs injected
r2 = sync_subjective_dimensions(plan, state)
@@ -634,7 +641,7 @@ def test_escalation_mid_cycle_reinserts_evicted_ids():
plan = _plan_with_queue("some_issue::file.py::abc123")
plan["plan_start_scores"] = {"strict": 50.0} # mid-cycle
state = _state_with_under_target_dimensions("naming_quality")
- state["issues"] = {
+ state["work_items"] = {
"some_issue::file.py::abc123": {
"id": "some_issue::file.py::abc123",
"status": "open",
diff --git a/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py b/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py
index 4262c367..71441567 100644
--- a/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py
+++ b/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py
@@ -49,7 +49,7 @@ def test_cycle_completed_injects_stale_despite_objective_backlog():
"""After a completed cycle, stale dims inject even with new objective issues."""
plan = _plan_with_queue("some_issue::file.py::abc123")
state = _state_with_stale_dimensions("design_coherence", "error_consistency")
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -73,7 +73,7 @@ def test_cycle_completed_appends_to_back():
"""Post-cycle stale injection appends to back, preserving existing order."""
plan = _plan_with_queue("issue_a", "issue_b")
state = _state_with_stale_dimensions("design_coherence")
- state["issues"]["issue_a"] = {
+ state["work_items"]["issue_a"] = {
"id": "issue_a", "status": "open", "detector": "smells",
}
@@ -90,7 +90,7 @@ def test_cycle_completed_injects_under_target_dims():
# Dimension is below target but NOT stale (no needs_review_refresh)
state = _state_with_stale_dimensions("design_coherence")
state["subjective_assessments"]["design_coherence"]["needs_review_refresh"] = False
- state["issues"]["some_issue::file.py::abc123"] = {
+ state["work_items"]["some_issue::file.py::abc123"] = {
"id": "some_issue::file.py::abc123",
"status": "open",
"detector": "smells",
@@ -121,7 +121,8 @@ def test_under_target_injected_when_no_objective_backlog():
def test_cycle_completed_no_stale_dims_no_injection():
"""cycle_just_completed has no effect when no stale dims exist."""
plan = _plan_with_queue("some_issue::file.py::abc123")
- state = {"issues": {}, "scan_count": 5}
+ work_items: dict[str, dict] = {}
+ state = {"work_items": work_items, "issues": work_items, "scan_count": 5}
result = sync_subjective_dimensions(plan, state, cycle_just_completed=True)
assert result.injected == []
@@ -164,10 +165,12 @@ def test_triage_appends_to_back():
plan = _plan_with_queue("issue_a", "issue_b")
plan["epic_triage_meta"] = {"issue_snapshot_hash": "old_hash"}
+ work_items = {
+ "review::file.py::abc": {"status": "open", "detector": "review"},
+ }
state = {
- "issues": {
- "review::file.py::abc": {"status": "open", "detector": "review"},
- },
+ "work_items": work_items,
+ "issues": work_items,
"scan_count": 5,
}
diff --git a/desloppify/tests/plan/test_subjective_policy.py b/desloppify/tests/plan/test_subjective_policy.py
index 90655a81..2dab3184 100644
--- a/desloppify/tests/plan/test_subjective_policy.py
+++ b/desloppify/tests/plan/test_subjective_policy.py
@@ -106,7 +106,10 @@ def test_objective_issues_counted():
_issue("u2", "unused"),
_issue("r1", "review"), # non-objective
)
- policy = compute_subjective_visibility(state)
+ policy = compute_subjective_visibility(
+ state,
+ plan={"queue_order": ["u1", "u2", "r1"], "skipped": {}},
+ )
assert policy.has_objective_backlog is True
assert policy.objective_count == 2
@@ -115,7 +118,10 @@ def test_suppressed_issues_excluded():
state = _state_with_issues(
_issue("u1", "unused", suppressed=True),
)
- policy = compute_subjective_visibility(state)
+ policy = compute_subjective_visibility(
+ state,
+ plan={"queue_order": ["u1"], "skipped": {}},
+ )
assert policy.has_objective_backlog is False
assert policy.objective_count == 0
@@ -124,7 +130,10 @@ def test_closed_issues_excluded():
state = _state_with_issues(
_issue("u1", "unused", status="resolved"),
)
- policy = compute_subjective_visibility(state)
+ policy = compute_subjective_visibility(
+ state,
+ plan={"queue_order": ["u1"], "skipped": {}},
+ )
assert policy.has_objective_backlog is False
@@ -135,7 +144,10 @@ def test_non_objective_detectors_excluded():
_issue("sr1", "subjective_review"),
_issue("sa1", "subjective_assessment"),
)
- policy = compute_subjective_visibility(state)
+ policy = compute_subjective_visibility(
+ state,
+ plan={"queue_order": ["r1", "c1", "sr1", "sa1"], "skipped": {}},
+ )
assert policy.has_objective_backlog is False
assert policy.objective_count == 0
diff --git a/desloppify/tests/plan/test_triage_phase_banner.py b/desloppify/tests/plan/test_triage_phase_banner.py
index ca6c7fad..76e1fd06 100644
--- a/desloppify/tests/plan/test_triage_phase_banner.py
+++ b/desloppify/tests/plan/test_triage_phase_banner.py
@@ -26,6 +26,22 @@ def test_banner_pending_when_objective_backlog_exists():
assert banner.startswith("TRIAGE PENDING")
+def test_banner_pending_when_stale_triage_is_deferred_behind_objective_backlog():
+ plan = empty_plan()
+ plan["plan_start_scores"] = {"strict": 72.0}
+ plan["epic_triage_meta"] = {"triaged_ids": ["review::old"]}
+ state = {
+ "issues": {
+ "obj-1": {"id": "obj-1", "status": "open", "detector": "complexity"},
+ "review::old": {"id": "review::old", "status": "open", "detector": "review"},
+ "review::new": {"id": "review::new", "status": "open", "detector": "review"},
+ }
+ }
+
+ banner = triage_phase_banner(plan, state)
+ assert banner.startswith("TRIAGE PENDING")
+
+
def test_banner_mode_when_no_objective_backlog():
plan = empty_plan()
plan["queue_order"] = list(TRIAGE_STAGE_IDS)
diff --git a/desloppify/tests/plan/test_unified_status_lifecycle.py b/desloppify/tests/plan/test_unified_status_lifecycle.py
index b9596d35..8538b159 100644
--- a/desloppify/tests/plan/test_unified_status_lifecycle.py
+++ b/desloppify/tests/plan/test_unified_status_lifecycle.py
@@ -17,7 +17,7 @@
skip_items,
unskip_items,
)
-from desloppify.engine._plan.reconcile import reconcile_plan_after_scan
+from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan
from desloppify.engine._plan.schema import empty_plan
from desloppify.engine._plan.skip_policy import (
skip_kind_needs_state_reopen,
@@ -212,9 +212,9 @@ def test_triage_apply_sets_triaged_out_in_state(self):
result = apply_triage_to_plan(plan, state, triage, trigger="test")
assert result.issues_dismissed == 1
# State status should be updated
- assert state["issues"]["b"]["status"] == "triaged_out"
+ assert state["work_items"]["b"]["status"] == "triaged_out"
# Non-dismissed issue should stay open
- assert state["issues"]["a"]["status"] == "open"
+ assert state["work_items"]["a"]["status"] == "open"
# ---------------------------------------------------------------------------
@@ -299,9 +299,9 @@ def test_reconcile_syncs_open_skipped_to_deferred(self):
"scan_count": 5,
}
reconcile_plan_after_scan(plan, state)
- assert state["issues"]["a"]["status"] == "deferred"
- assert state["issues"]["b"]["status"] == "triaged_out"
- assert state["issues"]["c"]["status"] == "open"
+ assert state["work_items"]["a"]["status"] == "deferred"
+ assert state["work_items"]["b"]["status"] == "triaged_out"
+ assert state["work_items"]["c"]["status"] == "open"
def test_reconcile_does_not_re_sync_already_correct(self):
plan = empty_plan()
@@ -315,7 +315,7 @@ def test_reconcile_does_not_re_sync_already_correct(self):
"scan_count": 5,
}
reconcile_plan_after_scan(plan, state)
- assert state["issues"]["a"]["status"] == "deferred"
+ assert state["work_items"]["a"]["status"] == "deferred"
def test_reconcile_resurfaces_and_reopens(self):
plan = empty_plan()
@@ -336,7 +336,7 @@ def test_reconcile_resurfaces_and_reopens(self):
result = reconcile_plan_after_scan(plan, state)
assert "a" in result.resurfaced
# State should be reopened from deferred back to open
- assert state["issues"]["a"]["status"] == "open"
+ assert state["work_items"]["a"]["status"] == "open"
# ---------------------------------------------------------------------------
@@ -444,7 +444,7 @@ def test_triage_dismiss_unskip_roundtrip(self):
dismissed_issues=[DismissedIssue(issue_id="x", reason="not needed")],
)
apply_triage_to_plan(plan, state, triage, trigger="test")
- assert state["issues"]["x"]["status"] == "triaged_out"
+ assert state["work_items"]["x"]["status"] == "triaged_out"
assert "x" in plan["skipped"]
# Unskip
diff --git a/desloppify/tests/review/batch/test_split_modules_direct.py b/desloppify/tests/review/batch/test_split_modules_direct.py
index 35bbd719..fcccce73 100644
--- a/desloppify/tests/review/batch/test_split_modules_direct.py
+++ b/desloppify/tests/review/batch/test_split_modules_direct.py
@@ -47,12 +47,12 @@ def test_import_shared_extract_reviewed_files_deduplicates():
assert reviewed == ["a.py", "b.py"]
-def test_import_shared_parse_payload_accepts_legacy_findings_alias():
- parsed = parse_review_import_payload(
- {"findings": [{"summary": "legacy payload"}]},
- mode_name="Holistic",
- )
- assert parsed.issues == [{"summary": "legacy payload"}]
+def test_import_shared_parse_payload_requires_canonical_issues_key():
+ with pytest.raises(ValueError, match="must contain 'issues'"):
+ parse_review_import_payload(
+ {"findings": [{"summary": "legacy payload"}]},
+ mode_name="Holistic",
+ )
def test_store_assessments_keeps_holistic_precedence():
diff --git a/desloppify/tests/review/context/test_context_builder_direct.py b/desloppify/tests/review/context/test_context_builder_direct.py
index 8662dd5a..7514faa7 100644
--- a/desloppify/tests/review/context/test_context_builder_direct.py
+++ b/desloppify/tests/review/context/test_context_builder_direct.py
@@ -6,7 +6,10 @@
from types import SimpleNamespace
from desloppify.intelligence.review._context.models import ReviewContext
-from desloppify.intelligence.review.context_builder import build_review_context_inner
+from desloppify.intelligence.review.context_builder import (
+ ReviewContextBuildServices,
+ build_review_context_inner,
+)
class _ZoneMap:
@@ -51,21 +54,23 @@ def test_build_review_context_inner_populates_sections() -> None:
lang,
state,
ReviewContext(),
- read_file_text_fn=lambda path: content_by_path.get(path),
- abs_path_fn=lambda path: path,
- rel_fn=lambda path: path,
- importer_count_fn=lambda entry: entry.get("importers", 0),
- default_review_module_patterns_fn=lambda content: ["service"] if "def" in content else [],
- func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"),
- class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"),
- name_prefix_re=re.compile(r"([a-z]+)"),
- error_patterns={
- "has_try": re.compile(r"\btry\b"),
- "has_raise": re.compile(r"\braise\b"),
- },
- gather_ai_debt_signals_fn=lambda file_contents, rel_fn: {"files": sorted(file_contents)},
- gather_auth_context_fn=lambda file_contents, rel_fn: {"auth_files": len(file_contents)},
- classify_error_strategy_fn=lambda content: "raises" if "raise" in content else "returns",
+ ReviewContextBuildServices(
+ read_file_text=lambda path: content_by_path.get(path),
+ abs_path=lambda path: path,
+ rel_path=lambda path: path,
+ importer_count=lambda entry: entry.get("importers", 0),
+ default_review_module_patterns=lambda content: ["service"] if "def" in content else [],
+ gather_ai_debt_signals=lambda file_contents, rel_fn: {"files": sorted(file_contents)},
+ gather_auth_context=lambda file_contents, rel_fn: {"auth_files": len(file_contents)},
+ classify_error_strategy=lambda content: "raises" if "raise" in content else "returns",
+ func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"),
+ class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"),
+ name_prefix_re=re.compile(r"([a-z]+)"),
+ error_patterns={
+ "has_try": re.compile(r"\btry\b"),
+ "has_raise": re.compile(r"\braise\b"),
+ },
+ ),
)
assert ctx.naming_vocabulary["total_names"] == 3
@@ -94,20 +99,24 @@ def test_build_review_context_inner_falls_back_to_default_module_patterns() -> N
lang,
{"issues": {}},
ReviewContext(),
- read_file_text_fn=lambda _path: "def run_task():\n return 1\n",
- abs_path_fn=lambda path: path,
- rel_fn=lambda path: path,
- importer_count_fn=lambda _entry: 0,
- default_review_module_patterns_fn=lambda _content: ["fallback_pattern", "fallback_pattern"],
- func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"),
- class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"),
- name_prefix_re=re.compile(r"([a-z]+)"),
- error_patterns={},
- gather_ai_debt_signals_fn=lambda _file_contents, rel_fn: {},
- gather_auth_context_fn=lambda _file_contents, rel_fn: {},
- classify_error_strategy_fn=lambda _content: "",
+ ReviewContextBuildServices(
+ read_file_text=lambda _path: "def run_task():\n return 1\n",
+ abs_path=lambda path: path,
+ rel_path=lambda path: path,
+ importer_count=lambda _entry: 0,
+ default_review_module_patterns=lambda _content: [
+ "fallback_pattern",
+ "fallback_pattern",
+ ],
+ gather_ai_debt_signals=lambda _file_contents, rel_fn: {},
+ gather_auth_context=lambda _file_contents, rel_fn: {},
+ classify_error_strategy=lambda _content: "",
+ func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"),
+ class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"),
+ name_prefix_re=re.compile(r"([a-z]+)"),
+ error_patterns={},
+ ),
)
assert ctx.module_patterns == {}
assert ctx.codebase_stats["avg_file_loc"] == 2
-
diff --git a/desloppify/tests/review/context/test_context_holistic_accessors_direct.py b/desloppify/tests/review/context/test_context_holistic_accessors_direct.py
index 2fd731f3..774de20f 100644
--- a/desloppify/tests/review/context/test_context_holistic_accessors_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_accessors_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._accessors import (
+from desloppify.intelligence.review.context_holistic.clusters.accessors import (
_get_detail,
_get_signals,
_safe_num,
@@ -30,4 +30,3 @@ def test_safe_num_accepts_numeric_but_rejects_bool_and_other_types() -> None:
assert _safe_num(2.5) == 2.5
assert _safe_num(True, default=9.0) == 9.0
assert _safe_num("3", default=1.5) == 1.5
-
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py
index ee663af2..d509b8e3 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._clusters_complexity import (
+from desloppify.intelligence.review.context_holistic.clusters.complexity import (
_build_complexity_hotspots,
)
@@ -62,4 +62,3 @@ def test_build_complexity_hotspots_limits_to_top_twenty() -> None:
assert len(hotspots) == 20
assert hotspots[0]["file"] == "src/f29.py"
assert hotspots[-1]["file"] == "src/f10.py"
-
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py
index f519c429..140c3f77 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._clusters_consistency import (
+from desloppify.intelligence.review.context_holistic.clusters.consistency import (
_build_duplicate_clusters,
_build_naming_drift,
)
@@ -46,4 +46,3 @@ def test_build_naming_drift_groups_by_directory_and_counts_outliers() -> None:
assert drift[0]["minority_count"] == 2
assert "src/app/FooBar.py" in drift[0]["outliers"]
assert drift[1]["directory"] == "src/lib/"
-
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py
index cb322d2a..567728ff 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._clusters_dependency import (
+from desloppify.intelligence.review.context_holistic.clusters.dependency import (
_build_boundary_violations,
_build_dead_code,
_build_deferred_import_density,
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py
index c82b5e34..c0704253 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._clusters_error_state import (
+from desloppify.intelligence.review.context_holistic.clusters.error_state import (
_build_error_hotspots,
_build_mutable_globals,
)
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py
index b079512b..5f759f4c 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from desloppify.intelligence.review.context_holistic._clusters_organization import (
+from desloppify.intelligence.review.context_holistic.clusters.organization import (
_build_flat_dir_issues,
_build_large_file_distribution,
)
diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py
index e65bb100..de57443a 100644
--- a/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py
+++ b/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py
@@ -4,7 +4,7 @@
from collections import Counter
-from desloppify.intelligence.review.context_holistic._clusters_security import (
+from desloppify.intelligence.review.context_holistic.clusters.security import (
_build_security_hotspots,
_build_signal_density,
_build_systemic_patterns,
diff --git a/desloppify/tests/review/context/test_holistic_review.py b/desloppify/tests/review/context/test_holistic_review.py
index 8a0adfdc..5c1894bf 100644
--- a/desloppify/tests/review/context/test_holistic_review.py
+++ b/desloppify/tests/review/context/test_holistic_review.py
@@ -526,7 +526,7 @@ def test_prepare_holistic_review_filters_out_of_scope_batch_files(
lang = _mock_lang([in_scope_file])
lang.name = "python"
state = empty_state()
- state["issues"] = {
+ state["work_items"] = {
"in_scope_structural": {
"id": "in_scope_structural",
"detector": "structural",
@@ -616,7 +616,7 @@ def test_basic_import(self):
diff = _call_import_holistic_issues(issues_data, state, "python")
assert diff["new"] == 1
- issues = list(state["issues"].values())
+ issues = list(state["work_items"].values())
assert len(issues) == 1
f = issues[0]
assert f["file"] == "."
@@ -639,7 +639,7 @@ def test_invalid_dimension_rejected(self):
diff = _call_import_holistic_issues(issues_data, state, "python")
assert diff["new"] == 0
- assert len(state["issues"]) == 0
+ assert len(state["work_items"]) == 0
def test_missing_fields_rejected(self):
state = empty_state()
@@ -677,7 +677,7 @@ def test_multiple_issues(self):
diff = _call_import_holistic_issues(issues_data, state, "python")
assert diff["new"] == 2
- assert len(state["issues"]) == 2
+ assert len(state["work_items"]) == 2
def test_holistic_cache_updated(self):
state = empty_state()
@@ -734,7 +734,7 @@ def test_reviewed_files_auto_resolves_per_file_coverage_markers(self, tmp_path):
state = empty_state()
coverage_id = "subjective_review::.::high_level_elegance"
- state["issues"][coverage_id] = {
+ state["work_items"][coverage_id] = {
"id": coverage_id,
"detector": "subjective_review",
"file": ".",
@@ -763,7 +763,7 @@ def test_reviewed_files_auto_resolves_per_file_coverage_markers(self, tmp_path):
diff = _call_import_holistic_issues(payload, state, "python", project_root=tmp_path)
assert diff["auto_resolved"] >= 1
- assert state["issues"][coverage_id]["status"] == "fixed"
+ assert state["work_items"][coverage_id]["status"] == "fixed"
def test_holistic_potential_added(self):
state = empty_state()
@@ -800,7 +800,7 @@ def test_issue_id_contains_holistic(self):
_call_import_holistic_issues(issues_data, state, "python")
- fid = list(state["issues"].keys())[0]
+ fid = list(state["work_items"].keys())[0]
assert "holistic" in fid
def test_positive_observation_skipped(self):
@@ -841,7 +841,7 @@ def test_positive_observation_skipped(self):
# Only the actual defect should be imported
assert diff["new"] == 1
assert diff.get("skipped", 0) == 2
- issues = list(state["issues"].values())
+ issues = list(state["work_items"].values())
assert len(issues) == 1
assert "vague_name" in issues[0]["id"]
diff --git a/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py b/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py
index f702f91b..9885e957 100644
--- a/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py
+++ b/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py
@@ -91,7 +91,7 @@ def _state_with_holistic_issues(*issues_args):
state["objective_score"] = 45.0
state["strict_score"] = 38.0
for fid, conf, dim, summary in issues_args:
- state["issues"][fid] = {
+ state["work_items"][fid] = {
"id": fid,
"file": ".",
"status": "open",
@@ -242,7 +242,7 @@ def test_resolved_issues_excluded(self):
),
)
# Add a resolved issue that should NOT appear
- state["issues"]["review::.::holistic::test::def"] = {
+ state["work_items"]["review::.::holistic::test::def"] = {
"id": "review::.::holistic::test::def",
"file": ".",
"status": "fixed",
diff --git a/desloppify/tests/review/context/test_issue_history_context.py b/desloppify/tests/review/context/test_issue_history_context.py
index 49dd466e..004d9f6d 100644
--- a/desloppify/tests/review/context/test_issue_history_context.py
+++ b/desloppify/tests/review/context/test_issue_history_context.py
@@ -75,7 +75,7 @@ def test_issue_history_returns_flat_recent_issues():
note="blocked by migration dependency",
resolved_at="2026-02-24T12:00:00+00:00",
)
- state["issues"] = {
+ state["work_items"] = {
f_open["id"]: f_open,
f_fixed["id"]: f_fixed,
f_wontfix["id"]: f_wontfix,
@@ -122,7 +122,7 @@ def test_issue_history_strips_auto_resolve_notes():
note="not reported in latest holistic re-import",
resolved_at="2026-02-24T11:00:00+00:00",
)
- state["issues"] = {f["id"]: f}
+ state["work_items"] = {f["id"]: f}
history = build_issue_history_context(state)
assert history["recent_issues"][0]["note"] == ""
@@ -130,7 +130,7 @@ def test_issue_history_strips_auto_resolve_notes():
def test_issue_history_respects_max_issues():
state = empty_state()
- state["issues"] = {}
+ state["work_items"] = {}
for idx in range(10):
f = _review_issue(
issue_id=f"review::.::holistic::abstraction_fitness::issue_{idx}",
@@ -139,7 +139,7 @@ def test_issue_history_respects_max_issues():
summary=f"Issue number {idx}",
last_seen=f"2026-02-{20 + idx % 5}T10:00:00+00:00",
)
- state["issues"][f["id"]] = f
+ state["work_items"][f["id"]] = f
history = build_issue_history_context(
state, options=ReviewHistoryOptions(max_issues=5)
@@ -164,7 +164,7 @@ def test_issue_history_sorted_by_last_seen():
last_seen="2026-02-24T10:00:00+00:00",
)
state = empty_state()
- state["issues"] = {f_old["id"]: f_old, f_new["id"]: f_new}
+ state["work_items"] = {f_old["id"]: f_old, f_new["id"]: f_new}
history = build_issue_history_context(state)
issues = history["recent_issues"]
@@ -181,7 +181,7 @@ def test_issue_history_empty_state():
def test_prepare_holistic_review_optional_issue_history_payload():
state = empty_state()
- state["issues"] = {
+ state["work_items"] = {
"review::.::holistic::error_consistency::mixed_error_channels_console_vs_pipeline": _review_issue(
issue_id="review::.::holistic::error_consistency::mixed_error_channels_console_vs_pipeline",
dimension="error_consistency",
diff --git a/desloppify/tests/review/context/test_mechanical_evidence.py b/desloppify/tests/review/context/test_mechanical_evidence.py
index 58ac1085..cd1aa1fd 100644
--- a/desloppify/tests/review/context/test_mechanical_evidence.py
+++ b/desloppify/tests/review/context/test_mechanical_evidence.py
@@ -495,6 +495,36 @@ def test_no_change_doesnt_mark_stale(self):
dc = state["subjective_assessments"]["design_coherence"]
assert "needs_review_refresh" not in dc
+ def test_fresh_trusted_import_survives_immediate_reconcile_scan(self):
+ from desloppify.engine._state.merge import merge_scan
+ from desloppify.engine._state.schema import empty_state
+
+ state = empty_state()
+ state["last_scan"] = "2026-03-13T13:09:25+00:00"
+ state["assessment_import_audit"] = [
+ {
+ "mode": "trusted_internal",
+ "timestamp": "2026-03-13T15:14:29+00:00",
+ }
+ ]
+ state["subjective_assessments"] = {
+ "design_coherence": {
+ "score": 79.0,
+ "assessed_at": "2026-03-13T15:14:29+00:00",
+ "source": "holistic",
+ },
+ }
+
+ new_issues = [
+ _issue(id="s1", detector="structural", file="big.py", detail={"loc": 500}),
+ ]
+ merge_scan(state, new_issues)
+
+ dc = state["subjective_assessments"]["design_coherence"]
+ assert "needs_review_refresh" not in dc
+ assert "refresh_reason" not in dc
+ assert "stale_since" not in dc
+
def test_already_stale_not_overwritten(self):
from desloppify.engine._state.merge import merge_scan
from desloppify.engine._state.schema import empty_state
@@ -589,7 +619,7 @@ def test_auto_resolved_detector_marks_its_dimensions_stale(self):
# Manually resolve the issue so verify_disappeared will process it
# (open issues are now user-controlled and skip verification)
- state["issues"]["structural::big.py::large_file"]["status"] = "fixed"
+ 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))
diff --git a/desloppify/tests/review/context/test_review_context_signals_direct.py b/desloppify/tests/review/context/test_review_context_signals_direct.py
index 5b969b8b..d84727b9 100644
--- a/desloppify/tests/review/context/test_review_context_signals_direct.py
+++ b/desloppify/tests/review/context/test_review_context_signals_direct.py
@@ -103,6 +103,17 @@ def test_gather_auth_context_ignores_non_source_guidance_files():
assert result == {}
+def test_gather_auth_context_ignores_runtime_extensions_in_guidance_paths() -> None:
+ file_contents = {
+ "guidance/auth_examples.py": "@app.get('/docs')\ndef route():\n request.user\n",
+ "prompts/security_prompt.ts": "const k = service_role; createClient(url, k)",
+ "src/routes/admin.py": "@app.get('/admin')\ndef route():\n return 1\n",
+ }
+ result = signal_auth_mod.gather_auth_context(file_contents, rel_fn=lambda p: p)
+ assert list(result["route_auth_coverage"]) == ["src/routes/admin.py"]
+ assert "service_role_usage" not in result
+
+
def test_gather_auth_context_counts_public_route_markers_separately():
file_contents = {
"api.py": (
diff --git a/desloppify/tests/review/import_scoring/test_review_import_scoring.py b/desloppify/tests/review/import_scoring/test_review_import_scoring.py
index df494370..3c54ae03 100644
--- a/desloppify/tests/review/import_scoring/test_review_import_scoring.py
+++ b/desloppify/tests/review/import_scoring/test_review_import_scoring.py
@@ -21,7 +21,7 @@ def test_import_valid_issues(self, empty_state, sample_issues_data):
diff = import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
assert diff["new"] == 3
# Check issues were added to state
- issues = empty_state["issues"]
+ issues = empty_state["work_items"]
assert len(issues) == 3
# Check issue IDs follow the pattern
ids = list(issues.keys())
@@ -55,7 +55,7 @@ def test_import_validates_confidence(self, empty_state):
}
]
import_review_issues(_as_review_payload(data), empty_state, "typescript")
- issue = list(empty_state["issues"].values())[0]
+ issue = list(empty_state["work_items"].values())[0]
assert issue["confidence"] == "low"
def test_import_validates_dimension(self, empty_state):
@@ -109,7 +109,7 @@ def test_import_preserves_wontfix_issues(self, empty_state, sample_issues_data):
# First import
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
# Mark one as wontfix
- for f in empty_state["issues"].values():
+ for f in empty_state["work_items"].values():
if "naming_quality" in f["id"]:
f["status"] = "wontfix"
f["note"] = "intentionally generic"
@@ -117,25 +117,25 @@ def test_import_preserves_wontfix_issues(self, empty_state, sample_issues_data):
# Second import with same issues
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
# Wontfix should NOT be auto-resolved (it's still in current issues)
- assert any(f["status"] == "wontfix" for f in empty_state["issues"].values())
+ assert any(f["status"] == "wontfix" for f in empty_state["work_items"].values())
# The issue still exists
assert any(
- "naming_quality" in f["id"] for f in empty_state["issues"].values()
+ "naming_quality" in f["id"] for f in empty_state["work_items"].values()
)
def test_import_sets_lang(self, empty_state, sample_issues_data):
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "python")
- for f in empty_state["issues"].values():
+ for f in empty_state["work_items"].values():
assert f["lang"] == "python"
def test_import_sets_tier_3(self, empty_state, sample_issues_data):
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
- for f in empty_state["issues"].values():
+ for f in empty_state["work_items"].values():
assert f["tier"] == 3
def test_import_stores_detail(self, empty_state, sample_issues_data):
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
- for f in empty_state["issues"].values():
+ for f in empty_state["work_items"].values():
assert "dimension" in f["detail"]
assert "suggestion" in f["detail"]
@@ -164,7 +164,7 @@ def test_id_collision_different_summaries(self, empty_state):
# Same file + dimension + identifier = same issue ID, even with different summaries.
# The second entry overwrites the first (last-writer wins during import).
assert diff["new"] == 1
- assert len(empty_state["issues"]) == 1
+ assert len(empty_state["work_items"]) == 1
def test_id_stable_for_same_summary(self, empty_state):
"""Same summary should produce the same issue ID (stable identifier)."""
@@ -178,12 +178,12 @@ def test_id_stable_for_same_summary(self, empty_state):
}
]
import_review_issues(_as_review_payload(data), empty_state, "typescript")
- ids_first = set(empty_state["issues"].keys())
+ ids_first = set(empty_state["work_items"].keys())
# Import again — should match same IDs (no new issues)
diff = import_review_issues(_as_review_payload(data), empty_state, "typescript")
assert diff["new"] == 0
- assert set(empty_state["issues"].keys()) == ids_first
+ assert set(empty_state["work_items"].keys()) == ids_first
# ── Scoring integration tests ─────────────────────────────────────
@@ -201,7 +201,7 @@ def test_review_issues_appear_in_scoring(self, empty_state, sample_issues_data):
}
potentials = {"review": 2}
dim_scores = compute_dimension_scores(
- empty_state["issues"], potentials, subjective_assessments=assessments
+ empty_state["work_items"], potentials, subjective_assessments=assessments
)
assert "Naming quality" in dim_scores
assert dim_scores["Naming quality"]["score"] == 75.0
@@ -215,7 +215,7 @@ def test_review_issues_not_auto_resolved_by_scan(
import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript")
review_ids = {
f["id"]
- for f in empty_state["issues"].values()
+ for f in empty_state["work_items"].values()
if f["detector"] == "review"
}
@@ -231,8 +231,8 @@ def test_review_issues_not_auto_resolved_by_scan(
# Review issues should still be open (not auto-resolved)
for fid in review_ids:
- if fid in empty_state["issues"]:
- assert empty_state["issues"][fid]["status"] == "open"
+ if fid in empty_state["work_items"]:
+ assert empty_state["work_items"][fid]["status"] == "open"
def test_review_in_file_based_detectors(self):
assert "review" in FILE_BASED_DETECTORS
@@ -267,7 +267,7 @@ def test_import_new_format_with_assessments(self):
}
diff = import_review_issues(_as_review_payload(data), state, "typescript")
assert diff["new"] == 1
- assert len(state["issues"]) == 1
+ assert len(state["work_items"]) == 1
assessments = state["subjective_assessments"]
assert "naming_quality" in assessments
assert assessments["naming_quality"]["score"] == 75
diff --git a/desloppify/tests/review/review_commands_cases.py b/desloppify/tests/review/review_commands_cases.py
index f68df3df..0158e4fd 100644
--- a/desloppify/tests/review/review_commands_cases.py
+++ b/desloppify/tests/review/review_commands_cases.py
@@ -169,12 +169,11 @@ def mock_save(state, sp):
lang = MagicMock()
lang.name = "typescript"
- # save_state is imported lazily: from ..state import save_state
- with patch("desloppify.state.save_state", mock_save):
+ with patch("desloppify.app.commands.review.importing.cmd.save_state", mock_save):
_do_import(str(issues_file), empty_state, lang, "fake_sp")
assert saved["sp"] == "fake_sp"
- assert len(empty_state["issues"]) == 1
+ assert len(empty_state["work_items"]) == 1
def test_do_prepare_prints_narrative_reminders(self, mock_lang_with_zones, empty_state, tmp_path, capsys):
from unittest.mock import MagicMock, patch
@@ -318,7 +317,7 @@ def mock_save(state, sp):
lang = MagicMock()
lang.name = "typescript"
- with patch("desloppify.state.save_state", mock_save):
+ with patch("desloppify.app.commands.review.importing.cmd.save_state", mock_save):
_do_import(
str(issues_file),
empty_state,
@@ -592,6 +591,7 @@ def test_attested_external_import_applies_durable_assessment(
audit = empty_state.get("assessment_import_audit", [])
assert audit and audit[-1]["mode"] == "attested_external"
assert audit[-1]["attested_external"] is True
+ assert audit[-1]["packet_sha256"] == packet_hash
def test_do_validate_import_reports_mode_without_state_mutation(
self, empty_state, tmp_path, capsys
@@ -706,12 +706,12 @@ def test_do_import_fails_closed_on_skipped_issues(self, empty_state, tmp_path):
lang = MagicMock()
lang.name = "typescript"
- with patch("desloppify.state.save_state") as mock_save:
+ with patch("desloppify.app.commands.review.importing.cmd.save_state") as mock_save:
with pytest.raises(CommandError):
_do_import(str(issues_file), empty_state, lang, "sp")
assert mock_save.called is False
assert empty_state.get("subjective_assessments", {}) == {}
- assert empty_state.get("issues", {}) == {}
+ assert empty_state.get("work_items", {}) == {}
def test_do_import_allow_partial_persists_when_overridden(
self, empty_state, tmp_path
@@ -736,7 +736,7 @@ def test_do_import_allow_partial_persists_when_overridden(
lang = MagicMock()
lang.name = "typescript"
- with patch("desloppify.state.save_state") as mock_save:
+ with patch("desloppify.app.commands.review.importing.cmd.save_state") as mock_save:
_do_import(
str(issues_file),
empty_state,
@@ -969,7 +969,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"high_level_elegance": {
"strengths": ["consistent module boundaries"],
- "issue_character": "structural coupling between subsystems",
+ "dimension_character": "structural coupling between subsystems",
"score_rationale": "Orchestration seams cross module boundaries creating coupling that impacts maintainability.",
},
},
@@ -1001,7 +1001,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"mid_level_elegance": {
"strengths": ["clear module separation"],
- "issue_character": "adapter protocol inconsistency across sibling modules",
+ "dimension_character": "adapter protocol inconsistency across sibling modules",
"score_rationale": "Handoff adapters diverge between sibling modules making the integration boundary harder to reason about.",
},
},
@@ -1033,7 +1033,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"high_level_elegance": {
"strengths": ["orchestration seams are mostly aligned"],
- "issue_character": "brittle edge seams in module boundary handling",
+ "dimension_character": "brittle edge seams in module boundary handling",
"score_rationale": "Most orchestration boundaries are consistent but edge seams remain brittle enough to risk regressions.",
},
},
@@ -1065,7 +1065,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"low_level_elegance": {
"strengths": ["concise local internals"],
- "issue_character": "repetitive branching boilerplate in local flow",
+ "dimension_character": "repetitive branching boilerplate in local flow",
"score_rationale": "Local function bodies are concise but repetitive branching patterns add unnecessary cognitive load.",
},
},
@@ -1208,7 +1208,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"mid_level_elegance": {
"strengths": ["seam boundaries are explicit"],
- "issue_character": "seam convention drift across adjacent modules",
+ "dimension_character": "seam convention drift across adjacent modules",
"score_rationale": "Adjacent modules use mostly explicit seams but conventions drift enough to cause confusion at boundaries.",
}
},
@@ -1326,7 +1326,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"mid_level_elegance": {
"strengths": ["aligned seam conventions"],
- "issue_character": "minor inconsistencies in seam boundary alignment",
+ "dimension_character": "minor inconsistencies in seam boundary alignment",
"score_rationale": "Seam conventions are mostly aligned but minor inconsistencies remain at module boundaries.",
}
},
@@ -1441,7 +1441,7 @@ def test_do_run_batches_recovers_missing_raw_output_from_log(
"dimension_judgment": {
"mid_level_elegance": {
"strengths": ["clear hook separation"],
- "issue_character": "overlapping orchestration seams across sibling hooks",
+ "dimension_character": "overlapping orchestration seams across sibling hooks",
"score_rationale": "Domain seams are split across sibling hooks causing overlapping orchestration that complicates reasoning.",
}
},
@@ -1597,7 +1597,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"mid_level_elegance": {
"strengths": ["explicit seam boundaries"],
- "issue_character": "seam style drift across nearby modules",
+ "dimension_character": "seam style drift across nearby modules",
"score_rationale": "Seam interfaces are explicit but style differences across nearby modules reduce consistency.",
}
},
@@ -1798,7 +1798,7 @@ def fake_subprocess_run(
"dimension_judgment": {
"abstraction_fitness": {
"strengths": ["interfaces are mostly honest about their contracts"],
- "issue_character": "excessive wrapper indirection before reaching domain logic",
+ "dimension_character": "excessive wrapper indirection before reaching domain logic",
"score_rationale": "Three wrapper layers before domain calls add significant indirection cost that outweighs abstraction leverage.",
},
},
@@ -2310,7 +2310,7 @@ def test_holistic_auto_resolve_on_reimport(self):
diff1 = import_holistic_issues(_as_review_payload(data1), state, "typescript")
assert diff1["new"] == 2
open_ids = [
- fid for fid, f in state["issues"].items() if f["status"] == "open"
+ fid for fid, f in state["work_items"].items() if f["status"] == "open"
]
assert len(open_ids) == 2
@@ -2333,7 +2333,7 @@ def test_holistic_auto_resolve_on_reimport(self):
# The 2 old issues should be marked fixed by the import.
assert diff2["auto_resolved"] >= 2
still_open = [
- fid for fid, f in state["issues"].items() if f["status"] == "open"
+ fid for fid, f in state["work_items"].items() if f["status"] == "open"
]
assert len(still_open) == 1
@@ -2365,7 +2365,7 @@ def test_partial_holistic_reimport_only_resolves_imported_dimensions(self):
diff1 = import_holistic_issues(_as_review_payload(data1), state, "typescript")
assert diff1["new"] == 2
- by_summary = {f["summary"]: fid for fid, f in state["issues"].items()}
+ by_summary = {f["summary"]: fid for fid, f in state["work_items"].items()}
cross_mod_id = by_summary["too central"]
abstraction_id = by_summary["dumping ground"]
@@ -2389,8 +2389,8 @@ def test_partial_holistic_reimport_only_resolves_imported_dimensions(self):
diff2 = import_holistic_issues(_as_review_payload(data2), state, "typescript")
assert diff2["new"] == 1
assert diff2["auto_resolved"] >= 1
- assert state["issues"][abstraction_id]["status"] == "fixed"
- assert state["issues"][cross_mod_id]["status"] == "open"
+ assert state["work_items"][abstraction_id]["status"] == "fixed"
+ assert state["work_items"][cross_mod_id]["status"] == "open"
def test_per_file_auto_resolve_on_reimport(self):
state = build_empty_state()
@@ -2433,7 +2433,7 @@ def test_per_file_auto_resolve_on_reimport(self):
# The comment_quality issue should be marked fixed by the explicit import.
resolved = [
f
- for f in state["issues"].values()
+ for f in state["work_items"].values()
if f["status"] == "fixed"
and "not reported in latest per-file" in (f.get("note") or "")
]
@@ -2457,7 +2457,7 @@ def test_holistic_does_not_resolve_per_file(self):
}
import_review_issues(_as_review_payload(per_file), state, "typescript")
per_file_ids = [
- fid for fid, f in state["issues"].items() if f["status"] == "open"
+ fid for fid, f in state["work_items"].items() if f["status"] == "open"
]
assert len(per_file_ids) == 1
@@ -2465,4 +2465,4 @@ def test_holistic_does_not_resolve_per_file(self):
holistic = {"issues": []}
import_holistic_issues(_as_review_payload(holistic), state, "typescript")
# Per-file issue should still be open
- assert state["issues"][per_file_ids[0]]["status"] == "open"
+ assert state["work_items"][per_file_ids[0]]["status"] == "open"
diff --git a/desloppify/tests/review/review_commands_runner_cases.py b/desloppify/tests/review/review_commands_runner_cases.py
index 2b6e5d8a..b766ba44 100644
--- a/desloppify/tests/review/review_commands_runner_cases.py
+++ b/desloppify/tests/review/review_commands_runner_cases.py
@@ -15,6 +15,7 @@
import desloppify.app.commands.review.runner_parallel as runner_parallel_mod
import desloppify.app.commands.runner.codex_batch as runner_process_mod
from desloppify.app.commands.review.batch.orchestrator import do_run_batches
+from desloppify.app.commands.review.batch.execution import CollectBatchResultsRequest
from desloppify.base.exception_sets import CommandError
runner_helpers_mod = SimpleNamespace(
@@ -67,10 +68,12 @@ def normalize_result(payload, _allowed_dims):
return payload.get("assessments", {}), payload.get("issues", []), notes, {}, {}, {}
batch_results, failures = runner_helpers_mod.collect_batch_results(
- selected_indexes=[0],
- failures=[0],
- output_files={0: output_file},
- allowed_dims={"logic_clarity"},
+ request=CollectBatchResultsRequest(
+ selected_indexes=[0],
+ failures=[0],
+ output_files={0: output_file},
+ allowed_dims={"logic_clarity"},
+ ),
extract_payload_fn=lambda raw: json.loads(raw),
normalize_result_fn=normalize_result,
)
@@ -115,10 +118,12 @@ def extract_payload(raw: str) -> dict[str, object] | None:
return None
batch_results, failures = runner_helpers_mod.collect_batch_results(
- selected_indexes=[0],
- failures=[],
- output_files={0: raw_path},
- allowed_dims={"logic_clarity"},
+ request=CollectBatchResultsRequest(
+ selected_indexes=[0],
+ failures=[],
+ output_files={0: raw_path},
+ allowed_dims={"logic_clarity"},
+ ),
extract_payload_fn=extract_payload,
normalize_result_fn=lambda payload, _allowed: ( # noqa: ARG005
payload.get("assessments", {}),
@@ -479,6 +484,102 @@ def test_do_run_batches_scan_after_import_exits_on_failed_followup(
assert exc_info.value.exit_code == 7
+ def test_do_run_batches_success_path_imports_merged_results(
+ self, empty_state, tmp_path
+ ):
+ packet = {
+ "command": "review",
+ "mode": "holistic",
+ "language": "typescript",
+ "dimensions": ["high_level_elegance"],
+ "investigation_batches": [
+ {
+ "name": "Batch A",
+ "dimensions": ["high_level_elegance"],
+ "files_to_read": ["src/a.ts"],
+ "why": "A",
+ }
+ ],
+ }
+ packet_path = tmp_path / "packet.json"
+ packet_path.write_text(json.dumps(packet))
+
+ args = MagicMock()
+ args.path = str(tmp_path)
+ args.dimensions = None
+ args.runner = "codex"
+ args.parallel = False
+ args.dry_run = False
+ args.packet = str(packet_path)
+ args.only_batches = None
+ args.scan_after_import = False
+ args.allow_partial = True
+ args.save_run_log = True
+ args.run_log_file = None
+
+ review_packet_dir = tmp_path / ".desloppify" / "review_packets"
+ runs_dir = tmp_path / ".desloppify" / "subagents" / "runs"
+
+ lang = MagicMock()
+ lang.name = "typescript"
+
+ with (
+ patch(
+ "desloppify.app.commands.review.runtime_paths.PROJECT_ROOT",
+ tmp_path,
+ ),
+ patch(
+ "desloppify.app.commands.review.runtime_paths.REVIEW_PACKET_DIR",
+ review_packet_dir,
+ ),
+ patch(
+ "desloppify.app.commands.review.runtime_paths.SUBAGENT_RUNS_DIR",
+ runs_dir,
+ ),
+ patch(
+ "desloppify.app.commands.review.batch.execution_phases.scored_dimensions_for_lang",
+ return_value=["high_level_elegance"],
+ ),
+ patch(
+ "desloppify.app.commands.review.batch.orchestrator.execute_batches",
+ return_value=[],
+ ),
+ patch(
+ "desloppify.app.commands.review.batch.orchestrator.collect_batch_results",
+ return_value=(
+ [
+ runner_parallel_mod.BatchResult(
+ batch_index=1,
+ assessments={"high_level_elegance": 84.0},
+ dimension_notes={},
+ issues=[],
+ quality={},
+ )
+ ],
+ [],
+ ),
+ ),
+ patch(
+ "desloppify.app.commands.review.batch.orchestrator._merge_batch_results",
+ return_value={
+ "assessments": {"high_level_elegance": 84.0},
+ "dimension_notes": {},
+ "issues": [],
+ "review_quality": {},
+ },
+ ),
+ patch(
+ "desloppify.app.commands.review.batch.orchestrator.run_followup_scan",
+ ) as run_followup_scan,
+ patch(
+ "desloppify.app.commands.review.batch.orchestrator._do_import",
+ ) as do_import,
+ ):
+ do_run_batches(args, empty_state, lang, "fake_sp", config={})
+
+ do_import.assert_called_once()
+ run_followup_scan.assert_not_called()
+
def test_do_run_batches_keyboard_interrupt_writes_partial_summary(
self, empty_state, tmp_path
):
@@ -552,4 +653,3 @@ def test_do_run_batches_keyboard_interrupt_writes_partial_summary(
run_log_path = Path(summary_payload["run_log"])
run_log_text = run_log_path.read_text()
assert "run-interrupted reason=keyboard_interrupt" in run_log_text
-
diff --git a/desloppify/tests/review/review_coverage_cases.py b/desloppify/tests/review/review_coverage_cases.py
index c4094caa..6fa2c62e 100644
--- a/desloppify/tests/review/review_coverage_cases.py
+++ b/desloppify/tests/review/review_coverage_cases.py
@@ -387,7 +387,7 @@ def test_same_identifier_collapses_with_evidence_lines(self):
_ = _call_import_review_issues(issues_data, state, "python")
# Same file+dimension+identifier → same issue ID (last writer wins)
- ids = list(state["issues"].keys())
+ ids = list(state["work_items"].keys())
assert len(ids) == 1
def test_same_identifier_collapses_without_evidence_lines(self):
@@ -417,7 +417,7 @@ def test_same_identifier_collapses_without_evidence_lines(self):
state = empty_state()
_ = _call_import_review_issues(issues_data, state, "python")
- ids = list(state["issues"].keys())
+ ids = list(state["work_items"].keys())
assert len(ids) == 1
def test_same_issue_same_id(self):
@@ -436,7 +436,7 @@ def test_same_issue_same_id(self):
]
state = empty_state()
_call_import_review_issues(issues_data, state, "python")
- id1 = list(state["issues"].keys())[0]
+ id1 = list(state["work_items"].keys())[0]
# Re-import same issue
state2 = empty_state()
@@ -485,7 +485,7 @@ def test_new_dimensions_accepted_by_import(self):
]
state = empty_state()
_call_import_review_issues(issues_data, state, "python")
- assert len(state["issues"]) == 1, f"Issue for {dim} was rejected"
+ assert len(state["work_items"]) == 1, f"Issue for {dim} was rejected"
# ── Registry and scoring integration ─────────────────────────────
diff --git a/desloppify/tests/review/review_misc_cases.py b/desloppify/tests/review/review_misc_cases.py
index c35f89d4..fad5ccb7 100644
--- a/desloppify/tests/review/review_misc_cases.py
+++ b/desloppify/tests/review/review_misc_cases.py
@@ -235,7 +235,7 @@ def test_headline_includes_review_in_maintenance(self):
open_by_detector={"review": 3},
)
assert headline is not None
- assert "review issue" in headline.lower()
+ assert "review work item" in headline.lower()
def test_headline_no_review_in_early_momentum(self):
headline = compute_headline(
diff --git a/desloppify/tests/review/review_misc_cases_headline_bugfix.py b/desloppify/tests/review/review_misc_cases_headline_bugfix.py
index 7f3a9748..aef6a551 100644
--- a/desloppify/tests/review/review_misc_cases_headline_bugfix.py
+++ b/desloppify/tests/review/review_misc_cases_headline_bugfix.py
@@ -9,7 +9,7 @@ class TestHeadlineBugFix:
def test_headline_no_typeerror_when_headline_none_with_review_suffix(self):
"""Regression: None + review_suffix shouldn't TypeError."""
# Force: no security prefix, headline_inner returns None, review_suffix non-empty
- # stagnation + review issues + conditions that make headline_inner return None
+ # stagnation + review work items + conditions that make headline_inner return None
result = compute_headline(
"stagnation",
{},
@@ -41,5 +41,5 @@ def test_headline_review_only_no_security_no_inner(self):
open_by_detector={"review": 3},
)
if result is not None:
- assert "review issue" in result.lower()
+ assert "review work item" in result.lower()
assert "3" in result
diff --git a/desloppify/tests/review/review_submodules_cases.py b/desloppify/tests/review/review_submodules_cases.py
index f37334d4..07f4b6a6 100644
--- a/desloppify/tests/review/review_submodules_cases.py
+++ b/desloppify/tests/review/review_submodules_cases.py
@@ -130,7 +130,7 @@ def test_empty_state(self, empty_state):
assert get_file_issues(empty_state, "src/foo.ts") == []
def test_finds_matching(self, empty_state):
- empty_state["issues"] = {
+ empty_state["work_items"] = {
"f1": {
"detector": "smells",
"file": "src/foo.ts",
diff --git a/desloppify/tests/review/review_submodules_import_and_remediation_cases.py b/desloppify/tests/review/review_submodules_import_and_remediation_cases.py
index d337c2a5..7d60a99f 100644
--- a/desloppify/tests/review/review_submodules_import_and_remediation_cases.py
+++ b/desloppify/tests/review/review_submodules_import_and_remediation_cases.py
@@ -48,7 +48,7 @@ def test_valid_issue(self, empty_state):
# Issue should be in state
assert any(
f.get("detector") == "review"
- for f in empty_state.get("issues", {}).values()
+ for f in empty_state.get("work_items", {}).values()
)
def test_skips_missing_fields(self, empty_state):
@@ -80,7 +80,7 @@ def test_normalizes_invalid_confidence(self, empty_state):
}
]
_ = import_review_issues(_as_review_payload(data), empty_state, "typescript")
- issues = list(empty_state.get("issues", {}).values())
+ issues = list(empty_state.get("work_items", {}).values())
review_issues = [f for f in issues if f.get("detector") == "review"]
assert len(review_issues) == 1
assert review_issues[0]["confidence"] == "low"
@@ -117,7 +117,7 @@ def test_auto_resolves_missing_issues(self, empty_state):
detail={"dimension": "naming_quality"},
)
old["lang"] = "typescript"
- empty_state["issues"][old["id"]] = old
+ empty_state["work_items"][old["id"]] = old
# Import new issues for same file, but different issue
data = [
{
@@ -130,7 +130,7 @@ def test_auto_resolves_missing_issues(self, empty_state):
]
_ = import_review_issues(_as_review_payload(data), empty_state, "typescript")
# Old issue should be marked fixed by the explicit import.
- assert empty_state["issues"][old["id"]]["status"] == "fixed"
+ assert empty_state["work_items"][old["id"]]["status"] == "fixed"
class TestImportHolisticIssues:
@@ -147,7 +147,7 @@ def test_valid_holistic(self, empty_state):
}
]
import_holistic_issues(_as_review_payload(data), empty_state, "typescript")
- issues = list(empty_state.get("issues", {}).values())
+ issues = list(empty_state.get("work_items", {}).values())
holistic = [f for f in issues if f.get("detail", {}).get("holistic")]
assert len(holistic) == 1
@@ -247,7 +247,7 @@ def test_with_issues(self, empty_state):
"reasoning": "Reduces coupling",
},
)
- empty_state["issues"][f["id"]] = f
+ empty_state["work_items"][f["id"]] = f
empty_state["objective_score"] = 85.0
empty_state["strict_score"] = 84.0
empty_state["potentials"] = {"typescript": {"review": 50}}
diff --git a/desloppify/tests/review/shared_review_fixtures.py b/desloppify/tests/review/shared_review_fixtures.py
index 8b84a34e..fee8cf52 100644
--- a/desloppify/tests/review/shared_review_fixtures.py
+++ b/desloppify/tests/review/shared_review_fixtures.py
@@ -25,7 +25,7 @@ def empty_state():
@pytest.fixture
def state_with_issues():
state = build_empty_state()
- state["issues"] = {
+ state["work_items"] = {
"unused::src/foo.ts::bar": {
"id": "unused::src/foo.ts::bar",
"detector": "unused",
diff --git a/desloppify/tests/review/test_work_queue_issues_direct.py b/desloppify/tests/review/test_work_queue_issues_direct.py
index 17cd93c3..d90a4a2f 100644
--- a/desloppify/tests/review/test_work_queue_issues_direct.py
+++ b/desloppify/tests/review/test_work_queue_issues_direct.py
@@ -48,7 +48,7 @@ def test_update_investigation_persists_detail_and_timestamp() -> None:
updated = issues_mod.update_investigation(state, "review::a", "looked into this")
assert updated is True
- detail = state["issues"]["review::a"]["detail"]
+ detail = state["work_items"]["review::a"]["detail"]
assert detail["existing"] == "value"
assert detail["investigation"] == "looked into this"
datetime.fromisoformat(detail["investigated_at"])
@@ -96,8 +96,8 @@ def test_mark_stale_holistic_marks_old_entries_stale_only() -> None:
expired = issues_mod.mark_stale_holistic(state, max_age_days=30)
assert expired == ["review::stale"]
- stale_issue = state["issues"]["review::stale"]
+ stale_issue = state["work_items"]["review::stale"]
assert stale_issue["status"] == "open"
assert stale_issue["note"].startswith("holistic review stale")
- assert state["issues"]["review::fresh"]["status"] == "open"
- assert state["issues"]["review::bad-time"]["status"] == "open"
+ assert state["work_items"]["review::fresh"]["status"] == "open"
+ assert state["work_items"]["review::bad-time"]["status"] == "open"
diff --git a/desloppify/tests/review/test_work_queue_plan_order_and_triage.py b/desloppify/tests/review/test_work_queue_plan_order_and_triage.py
index d26d3ae8..e539d058 100644
--- a/desloppify/tests/review/test_work_queue_plan_order_and_triage.py
+++ b/desloppify/tests/review/test_work_queue_plan_order_and_triage.py
@@ -120,12 +120,13 @@ def test_plan_ordered_stale_subjective_gated_with_objective_backlog():
}
}
- # Without plan: stale subjective item is gated
+ # Without an explicit queue, pre-triage objective work still blocks stale
+ # subjective review items from surfacing.
queue_no_plan = build_work_queue(state, count=None, include_subjective=True)
subj_no_plan = [
i["id"] for i in queue_no_plan["items"] if i["id"].startswith("subjective::")
]
- assert len(subj_no_plan) == 0
+ assert subj_no_plan == []
# With plan that includes the stale dim in queue_order: still gated
plan = empty_plan()
@@ -256,6 +257,206 @@ def test_legacy_force_visible_triage_stage_is_ignored_during_execute():
assert ids == ["smells::src/a.py::x"]
+def test_stale_triage_surfaces_observe_instead_of_empty_queue():
+ """When new review findings exist after triage, next should surface triage recovery."""
+ from desloppify.engine._plan.schema import empty_plan
+
+ review_issue = _issue(
+ "review::.::holistic::design_coherence::needs_triage",
+ detector="review",
+ tier=4,
+ confidence="high",
+ detail={"dimension": "design_coherence", "holistic": True},
+ )
+ state = _state([review_issue])
+ state["scan_count"] = 7
+
+ plan = empty_plan()
+ plan["queue_order"] = [review_issue["id"]]
+ plan["refresh_state"] = {
+ "lifecycle_phase": "review_postflight",
+ "postflight_scan_completed_at_scan_count": 7,
+ }
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::.::holistic::older::already_triaged"],
+ "triage_stages": {
+ "observe": {"confirmed_at": "2026-03-13T14:00:00+00:00"},
+ "reflect": {"confirmed_at": "2026-03-13T14:01:00+00:00"},
+ },
+ }
+
+ queue = build_work_queue(state, count=None, include_subjective=True, plan=plan)
+ ids = [item["id"] for item in queue["items"]]
+ assert ids[0] == "triage::observe"
+
+
+def test_postflight_synthetic_queue_keeps_objective_backlog_suppressed():
+ """Synthetic-only postflight work must not reactivate implicit execute mode."""
+ from desloppify.engine._plan.schema import empty_plan
+
+ state = _state(
+ [
+ _issue("smells::src/a.py::x", detector="smells", tier=3),
+ _issue("smells::src/b.py::x", detector="smells", tier=3),
+ ],
+ dimension_scores={
+ "Naming quality": {
+ "score": 82.0,
+ "strict": 82.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "naming_quality"},
+ },
+ },
+ "Design coherence": {
+ "score": 73.0,
+ "strict": 73.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "design_coherence"},
+ },
+ },
+ },
+ )
+ state["subjective_assessments"] = {
+ "naming_quality": {"score": 82.0},
+ "design_coherence": {
+ "score": 73.0,
+ "needs_review_refresh": True,
+ "stale_since": "2026-01-01T00:00:00+00:00",
+ },
+ }
+
+ plan = empty_plan()
+ plan["queue_order"] = ["workflow::communicate-score", "workflow::create-plan"]
+ plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 15}
+
+ queue = build_work_queue(
+ state, count=None, include_subjective=True, plan=plan,
+ )
+ ids = [item["id"] for item in queue["items"]]
+ assert all(fid.startswith("subjective::") for fid in ids)
+
+
+def test_explicit_planned_issue_bypasses_standalone_threshold_filter():
+ """Explicit queue_order items must still surface even when naturally filtered."""
+ from desloppify.engine._plan.schema import empty_plan
+
+ state = _state([
+ _issue(
+ "facade::src/a.py",
+ detector="facade",
+ file="src/a.py",
+ tier=2,
+ confidence="medium",
+ ),
+ ])
+ plan = empty_plan()
+ plan["queue_order"] = ["facade::src/a.py"]
+
+ queue = build_work_queue(state, count=None, include_subjective=True, plan=plan)
+
+ assert [item["id"] for item in queue["items"]] == ["facade::src/a.py"]
+
+
+def test_triaged_review_findings_stay_postflight_while_objective_work_remains():
+ """Completed triage should not mix review findings into execute."""
+ from desloppify.engine._plan.schema import empty_plan
+
+ state = _state(
+ [
+ _issue("smells::src/a.py::x", detector="smells", tier=3),
+ _issue(
+ "review::src/a.py::naming",
+ detector="review",
+ tier=1,
+ confidence="high",
+ detail={"dimension": "naming_quality"},
+ ),
+ ]
+ )
+ plan = empty_plan()
+ plan["plan_start_scores"] = {"strict": 80.0}
+ plan["queue_order"] = ["review::src/a.py::naming", "smells::src/a.py::x"]
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::src/a.py::naming"],
+ "last_completed_at": "2026-03-13T00:00:00+00:00",
+ }
+ plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1}
+
+ queue = build_execution_queue(
+ state,
+ options=QueueBuildOptions(
+ count=None,
+ include_subjective=False,
+ plan=plan,
+ ),
+ )
+ ids = [item["id"] for item in queue["items"]]
+ assert ids == ["smells::src/a.py::x"]
+
+
+def test_postflight_assessment_precedes_review_findings():
+ """Postflight subjective reruns gate later review execution work."""
+ from desloppify.engine._plan.schema import empty_plan
+
+ state = _state(
+ [
+ _issue(
+ "review::src/a.py::naming",
+ detector="review",
+ tier=1,
+ confidence="high",
+ detail={"dimension": "naming_quality"},
+ ),
+ _issue(
+ "subjective_review::naming_quality",
+ detector="subjective_review",
+ tier=1,
+ confidence="high",
+ detail={"dimension": "naming_quality"},
+ ),
+ ],
+ dimension_scores={
+ "Naming quality": {
+ "score": 70.0,
+ "strict": 70.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "naming_quality"},
+ },
+ },
+ },
+ )
+ state["subjective_assessments"] = {
+ "naming_quality": {
+ "score": 70.0,
+ "needs_review_refresh": True,
+ "stale_since": "2026-01-01T00:00:00+00:00",
+ }
+ }
+ plan = empty_plan()
+ plan["plan_start_scores"] = {"strict": 80.0}
+ plan["epic_triage_meta"] = {
+ "triaged_ids": ["review::src/a.py::naming"],
+ "last_completed_at": "2026-03-13T00:00:00+00:00",
+ }
+ plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1}
+
+ queue = build_execution_queue(
+ state,
+ options=QueueBuildOptions(
+ count=None,
+ include_subjective=True,
+ plan=plan,
+ ),
+ )
+ ids = [item["id"] for item in queue["items"]]
+ # Subjective dimension item is suppressed when review issues cover the
+ # same dimension — the assessment request alone surfaces.
+ assert ids == ["subjective_review::naming_quality"]
+
+
def test_execution_queue_excludes_unplanned_objective_items():
"""Unplanned objective items don't appear in execution — only planned items do."""
from desloppify.engine._plan.schema import empty_plan
@@ -282,7 +483,7 @@ def test_execution_queue_excludes_unplanned_objective_items():
def test_backlog_queue_excludes_execution_objective_items():
- """Backlog should exclude objective work already admitted to execution."""
+ """Backlog should exclude execution items and synthetic workflow helpers."""
from desloppify.engine._plan.schema import empty_plan
state = _state(
@@ -304,8 +505,8 @@ def test_backlog_queue_excludes_execution_objective_items():
)
ids = [item["id"] for item in queue["items"]]
assert "smells::src/a.py::planned" not in ids
- assert "smells::src/b.py::unplanned" not in ids
- assert "workflow::run-scan" in ids
+ assert "smells::src/b.py::unplanned" in ids
+ assert "workflow::run-scan" not in ids
def test_unplanned_objective_items_dont_block_postflight():
@@ -444,7 +645,13 @@ def test_wontfixed_issues_excluded_from_queue():
]
)
- queue = build_work_queue(state, count=None, include_subjective=False)
+ queue = build_backlog_queue(
+ state,
+ options=QueueBuildOptions(
+ count=None,
+ include_subjective=False,
+ ),
+ )
ids = {item["id"] for item in queue["items"]}
assert "a" in ids
assert "d" in ids
@@ -523,7 +730,7 @@ def test_triage_stages_hidden_during_initial_reviews():
def test_subjective_phase_precedes_score_and_triage_when_objective_drained():
- """With no objective backlog, stale/under-target subjective reruns come first."""
+ """Subjective reruns stay ahead of workflow and triage once postflight begins."""
state = _state(
[],
dimension_scores={
@@ -588,3 +795,49 @@ def test_triage_stages_sort_after_workflow_in_natural_ranking():
wf_key = item_sort_key(workflow_item)
tr_key = item_sort_key(triage_item)
assert wf_key < tr_key, "workflow actions should sort before triage stages"
+
+
+def test_fresh_under_target_postflight_review_preempts_persisted_workflow() -> None:
+ """Fresh below-target postflight review surfaces before queued workflow items."""
+ state = _state(
+ [],
+ dimension_scores={
+ "Naming quality": {
+ "score": 82.0,
+ "strict": 82.0,
+ "failing": 0,
+ "checks": 1,
+ "detectors": {
+ "subjective_assessment": {
+ "dimension_key": "naming_quality",
+ "placeholder": False,
+ },
+ },
+ },
+ },
+ )
+ state["scan_count"] = 19
+ state["subjective_assessments"] = {
+ "naming_quality": {
+ "score": 82.0,
+ "placeholder": False,
+ }
+ }
+ plan = {
+ "queue_order": ["workflow::communicate-score", "workflow::create-plan"],
+ "queue_skipped": {},
+ "refresh_state": {
+ "postflight_scan_completed_at_scan_count": 19,
+ "lifecycle_phase": "workflow_postflight",
+ },
+ }
+
+ queue = build_work_queue(state, count=None, include_subjective=True, plan=plan)
+ assert [item["id"] for item in queue["items"]] == ["subjective::naming_quality"]
+
+ plan["refresh_state"]["subjective_review_completed_at_scan_count"] = 19
+ queue = build_work_queue(state, count=None, include_subjective=True, plan=plan)
+ assert [item["id"] for item in queue["items"]] == [
+ "workflow::communicate-score",
+ "workflow::create-plan",
+ ]
diff --git a/desloppify/tests/review/work_queue_cases.py b/desloppify/tests/review/work_queue_cases.py
index 7240f4bd..af65a8e5 100644
--- a/desloppify/tests/review/work_queue_cases.py
+++ b/desloppify/tests/review/work_queue_cases.py
@@ -33,8 +33,10 @@ def _issue(
def _state(issues: list[dict], *, dimension_scores: dict | None = None) -> dict:
+ work_items = {f["id"]: f for f in issues}
return {
- "issues": {f["id"]: f for f in issues},
+ "work_items": work_items,
+ "issues": work_items,
"dimension_scores": dimension_scores or {},
}
@@ -230,12 +232,10 @@ def test_subjective_item_uses_show_review_when_matching_review_issues_exist():
queue = build_work_queue(
state, count=None, include_subjective=True, subjective_threshold=95
)
- subj = next(
- item for item in queue["items"] if item["kind"] == "subjective_dimension"
- )
- assert subj["id"] == "subjective::mid_level_elegance"
- assert subj["primary_command"] == "desloppify show review --status open"
- assert subj["detail"]["open_review_issues"] == 1
+ item = queue["items"][0]
+ assert item["id"] == "review::.::holistic::mid_level_elegance::split"
+ assert item["kind"] == "issue"
+ assert all(entry["kind"] != "subjective_dimension" for entry in queue["items"])
def test_stale_subjective_item_uses_show_review_when_matching_review_issues_exist():
@@ -272,12 +272,10 @@ def test_stale_subjective_item_uses_show_review_when_matching_review_issues_exis
queue = build_work_queue(
state, count=None, include_subjective=True, subjective_threshold=95
)
- subj = next(
- item for item in queue["items"] if item["kind"] == "subjective_dimension"
- )
- assert "[stale — re-review]" in subj["summary"]
- assert subj["primary_command"] == "desloppify show review --status open"
- assert subj["detail"]["open_review_issues"] == 1
+ item = queue["items"][0]
+ assert item["id"] == "review::.::holistic::initialization_coupling::abc12345"
+ assert item["kind"] == "issue"
+ assert all(entry["kind"] != "subjective_dimension" for entry in queue["items"])
def test_unassessed_subjective_item_points_to_holistic_refresh():
@@ -604,6 +602,48 @@ def test_stale_subjective_appear_when_no_objective_backlog():
assert "subjective::naming_quality" in ids
+def test_under_target_subjective_appear_when_no_objective_backlog():
+ """Current below-target dimensions surface alongside stale review work."""
+ state = _state(
+ [],
+ dimension_scores={
+ "Naming quality": {
+ "score": 70.0,
+ "strict": 70.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "naming_quality"},
+ },
+ },
+ "Design coherence": {
+ "score": 68.0,
+ "strict": 68.0,
+ "failing": 1,
+ "detectors": {
+ "subjective_assessment": {"dimension_key": "design_coherence"},
+ },
+ "stale": True,
+ },
+ },
+ )
+ state["subjective_assessments"] = {
+ "naming_quality": {
+ "score": 70.0,
+ "needs_review_refresh": False,
+ },
+ "design_coherence": {
+ "score": 68.0,
+ "needs_review_refresh": True,
+ "stale_since": "2026-01-01T00:00:00+00:00",
+ },
+ }
+
+ queue = build_work_queue(state, count=None, include_subjective=True)
+ ids = {item["id"] for item in queue["items"]}
+
+ assert all(fid.startswith("subjective::") for fid in ids)
+
+
def test_unassessed_subjective_visible_with_objective_backlog():
"""When initial reviews exist, only they are shown — objective items hidden.
@@ -772,7 +812,7 @@ def test_evidence_only_issue_still_in_state():
issues = [_issue("props::src/a.tsx::big", detector="props", confidence="low")]
state = _state(issues)
# Issue exists in state
- assert "props::src/a.tsx::big" in state["issues"]
+ assert "props::src/a.tsx::big" in state["work_items"]
# But not in queue
queue = build_work_queue(state, count=None, include_subjective=False)
assert len(queue["items"]) == 0
diff --git a/desloppify/tests/state/test_state.py b/desloppify/tests/state/test_state.py
index dbe157e7..ad519e81 100644
--- a/desloppify/tests/state/test_state.py
+++ b/desloppify/tests/state/test_state.py
@@ -5,6 +5,8 @@
from desloppify.engine._state import filtering as state_query_mod
+from desloppify.engine._state.issue_semantics import MECHANICAL_DEFECT, SCAN_ORIGIN
+from desloppify.engine._state.schema import CURRENT_VERSION
from desloppify.state import (
MergeScanOptions,
apply_issue_noise_budget,
@@ -238,6 +240,8 @@ def test_default_field_values(self, monkeypatch):
assert f["tier"] == 2
assert f["confidence"] == "medium"
assert f["summary"] == "sum"
+ assert f["issue_kind"] == MECHANICAL_DEFECT
+ assert f["origin"] == SCAN_ORIGIN
# ---------------------------------------------------------------------------
@@ -248,7 +252,7 @@ def test_default_field_values(self, monkeypatch):
class TestEmptyState:
def test_structure(self):
s = empty_state()
- assert s["version"] == 1
+ assert s["version"] == CURRENT_VERSION
assert s["last_scan"] is None
assert s["scan_count"] == 0
assert "config" not in s # config moved to config.json
@@ -269,7 +273,7 @@ def test_structure(self):
class TestLoadState:
def test_nonexistent_file_returns_empty_state(self, tmp_path):
s = load_state(tmp_path / "missing.json")
- assert s["version"] == 1
+ assert s["version"] == CURRENT_VERSION
assert s["issues"] == {}
def test_valid_json_returns_parsed_data(self, tmp_path):
@@ -290,6 +294,18 @@ def test_legacy_payload_gets_normalized(self, tmp_path):
assert s["scan_count"] == 0
assert s["stats"] == {}
assert s["issues"]["x"]["status"] == "open"
+ assert s["issues"]["x"]["issue_kind"] == MECHANICAL_DEFECT
+ assert s["issues"]["x"]["origin"] == SCAN_ORIGIN
+ validate_state_invariants(s)
+
+ def test_work_items_payload_gets_normalized(self, tmp_path):
+ p = tmp_path / "state.json"
+ p.write_text(
+ json.dumps({"version": 2, "work_items": {"x": {"id": "x", "tier": 3}}})
+ )
+ s = load_state(p)
+ assert s["issues"]["x"]["status"] == "open"
+ assert s["issues"]["x"]["work_item_kind"] == MECHANICAL_DEFECT
validate_state_invariants(s)
def test_corrupt_json_tries_backup(self, tmp_path):
@@ -306,7 +322,7 @@ def test_corrupt_json_no_backup_returns_empty(self, tmp_path):
p = tmp_path / "state.json"
p.write_text("{bad json!!")
s = load_state(p)
- assert s["version"] == 1
+ assert s["version"] == CURRENT_VERSION
assert s["issues"] == {}
def test_corrupt_json_renames_file(self, tmp_path):
@@ -322,7 +338,7 @@ def test_corrupt_json_and_corrupt_backup_returns_empty(self, tmp_path):
backup.write_text("{also bad")
s = load_state(p)
- assert s["version"] == 1
+ assert s["version"] == CURRENT_VERSION
assert s["issues"] == {}
@@ -338,7 +354,9 @@ def test_creates_file_and_writes_valid_json(self, tmp_path):
save_state(st, p)
assert p.exists()
loaded = json.loads(p.read_text())
- assert loaded["version"] == 1
+ assert loaded["version"] == CURRENT_VERSION
+ assert "work_items" in loaded
+ assert "issues" not in loaded
def test_creates_backup_of_previous(self, tmp_path):
p = tmp_path / "state.json"
@@ -370,6 +388,7 @@ def test_atomic_write_produces_valid_json(self, tmp_path):
loaded = json.loads(p.read_text())
assert loaded["custom_set"] == [1, 2, 3] # sorted
assert loaded["custom_path"] == "/tmp/hello"
+ assert "work_items" in loaded
def test_invalid_status_gets_normalized_before_save(self, tmp_path):
p = tmp_path / "state.json"
@@ -378,7 +397,7 @@ def test_invalid_status_gets_normalized_before_save(self, tmp_path):
ensure_state_defaults(st)
save_state(st, p)
loaded = json.loads(p.read_text())
- assert loaded["issues"]["x"]["status"] == "open"
+ assert loaded["work_items"]["x"]["status"] == "open"
# ---------------------------------------------------------------------------
@@ -603,19 +622,31 @@ def test_mechanical_auto_resolved_still_reopened(self):
class TestMissingIssuesResolved:
"""Issues present in state but absent from scan stay user-controlled."""
- def test_missing_issue_stays_open(self):
- """An open issue that disappears from scan remains open until resolved."""
+ def test_missing_issue_stays_open_when_file_exists(self, tmp_path):
+ """An open issue whose file still exists stays open until resolved."""
+ (tmp_path / "a.py").write_text("# exists")
st = empty_state()
old = _make_raw_issue("det::a.py::fn", detector="det", file="a.py")
old["lang"] = "python"
st["issues"]["det::a.py::fn"] = old
- # Merge an empty scan — the old issue stays open.
- diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True))
+ diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path)))
assert diff["auto_resolved"] == 0
assert st["issues"]["det::a.py::fn"]["status"] == "open"
assert st["issues"]["det::a.py::fn"]["resolved_at"] is None
+ def test_missing_issue_auto_resolved_when_file_deleted(self, tmp_path):
+ """An open issue for a deleted file is auto-resolved on rescan."""
+ st = empty_state()
+ old = _make_raw_issue("det::a.py::fn", detector="det", file="a.py")
+ old["lang"] = "python"
+ st["issues"]["det::a.py::fn"] = old
+
+ diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path)))
+ assert diff["auto_resolved"] == 1
+ assert st["issues"]["det::a.py::fn"]["status"] == "auto_resolved"
+ assert "no longer exists" in st["issues"]["det::a.py::fn"]["note"]
+
def test_missing_fixed_issue_gets_scan_verified(self):
"""A manually fixed issue stays fixed and gains scan corroboration."""
st = empty_state()
@@ -852,4 +883,3 @@ def test_zero_active_checks_with_assessments_keeps_subjective_scoring(self):
# Overall/strict are dragged down by the low assessment score.
assert st["overall_score"] < 100.0
assert st["strict_score"] < 100.0
-
diff --git a/desloppify/tests/state/test_state_internal_direct.py b/desloppify/tests/state/test_state_internal_direct.py
index 547ee288..4cca4f90 100644
--- a/desloppify/tests/state/test_state_internal_direct.py
+++ b/desloppify/tests/state/test_state_internal_direct.py
@@ -5,6 +5,7 @@
import json
import desloppify.engine._state.filtering as filtering_mod
+import desloppify.engine._state.issue_semantics as issue_semantics_mod
import desloppify.engine._state.noise as noise_mod
import desloppify.engine._state.persistence as persistence_mod
import desloppify.engine._state.resolution as resolution_mod
@@ -74,6 +75,64 @@ def test_load_state_missing_and_backup_fallback(tmp_path):
assert recovered["strict_score"] == 0
+def test_issue_semantics_normalize_legacy_detector_rows():
+ review_issue = {"id": "review::src/a.py::naming", "detector": "review", "detail": {}}
+ concern_issue = {"id": "concerns::src/a.py::dup", "detector": "concerns", "detail": {}}
+ request_issue = {
+ "id": "subjective_review::.::holistic_unreviewed",
+ "detector": "subjective_review",
+ "detail": {},
+ }
+ mechanical_issue = {"id": "unused::src/a.py::x", "detector": "unused", "detail": {}}
+
+ issue_semantics_mod.ensure_work_item_semantics(review_issue)
+ issue_semantics_mod.ensure_work_item_semantics(concern_issue)
+ issue_semantics_mod.ensure_work_item_semantics(request_issue)
+ issue_semantics_mod.ensure_work_item_semantics(mechanical_issue)
+
+ assert review_issue["work_item_kind"] == issue_semantics_mod.REVIEW_DEFECT
+ assert review_issue["issue_kind"] == issue_semantics_mod.REVIEW_DEFECT
+ assert review_issue["origin"] == issue_semantics_mod.REVIEW_IMPORT_ORIGIN
+ assert concern_issue["work_item_kind"] == issue_semantics_mod.REVIEW_CONCERN
+ assert concern_issue["issue_kind"] == issue_semantics_mod.REVIEW_CONCERN
+ assert request_issue["work_item_kind"] == issue_semantics_mod.ASSESSMENT_REQUEST
+ assert request_issue["issue_kind"] == issue_semantics_mod.ASSESSMENT_REQUEST
+ assert request_issue["origin"] == issue_semantics_mod.SYNTHETIC_TASK_ORIGIN
+ assert mechanical_issue["work_item_kind"] == issue_semantics_mod.MECHANICAL_DEFECT
+ assert mechanical_issue["issue_kind"] == issue_semantics_mod.MECHANICAL_DEFECT
+ assert mechanical_issue["origin"] == issue_semantics_mod.SCAN_ORIGIN
+
+
+def test_validate_state_invariants_rejects_invalid_issue_semantics():
+ state = schema_mod.empty_state()
+ state["work_items"] = {
+ "bad": {
+ "id": "bad",
+ "detector": "unused",
+ "file": "src/a.py",
+ "tier": 2,
+ "confidence": "high",
+ "summary": "bad",
+ "detail": {},
+ "status": "open",
+ "note": None,
+ "first_seen": "2025-01-01T00:00:00+00:00",
+ "last_seen": "2025-01-01T00:00:00+00:00",
+ "resolved_at": None,
+ "reopen_count": 0,
+ "issue_kind": "not_real",
+ "origin": issue_semantics_mod.SCAN_ORIGIN,
+ }
+ }
+
+ try:
+ schema_mod.validate_state_invariants(state)
+ except ValueError as exc:
+ assert "work_item_kind" in str(exc)
+ else:
+ raise AssertionError("validate_state_invariants should reject invalid issue_kind")
+
+
def test_state_persistence_defaults_follow_runtime_project_root(tmp_path):
from desloppify.base.runtime_state import RuntimeContext, runtime_scope
@@ -122,7 +181,7 @@ def test_match_and_resolve_issues_updates_state():
)
hidden_issue["suppressed"] = True
- state["issues"] = {
+ state["work_items"] = {
open_issue["id"]: open_issue,
hidden_issue["id"]: hidden_issue,
}
@@ -140,7 +199,7 @@ def test_match_and_resolve_issues_updates_state():
)
assert resolved_ids == [open_issue["id"]]
- resolved = state["issues"][open_issue["id"]]
+ resolved = state["work_items"][open_issue["id"]]
assert resolved["status"] == "fixed"
assert resolved["note"] == "done"
assert resolved["resolved_at"] is not None
@@ -195,7 +254,7 @@ def test_resolve_fixed_review_marks_assessment_stale_preserves_score():
summary="naming issue",
detail={"dimension": "naming_quality"},
)
- state["issues"] = {review_issue["id"]: review_issue}
+ state["work_items"] = {review_issue["id"]: review_issue}
state["subjective_assessments"] = {
"naming_quality": {"score": 82, "source": "holistic"},
"logic_clarity": {"score": 74, "source": "holistic"},
@@ -233,7 +292,7 @@ def test_resolve_wontfix_review_marks_assessment_stale():
summary="naming issue",
detail={"dimension": "naming_quality"},
)
- state["issues"] = {review_issue["id"]: review_issue}
+ state["work_items"] = {review_issue["id"]: review_issue}
state["subjective_assessments"] = {
"naming_quality": {"score": 82, "source": "holistic"}
}
@@ -265,7 +324,7 @@ def test_resolve_false_positive_review_marks_assessment_stale():
summary="naming issue",
detail={"dimension": "naming_quality"},
)
- state["issues"] = {review_issue["id"]: review_issue}
+ state["work_items"] = {review_issue["id"]: review_issue}
state["subjective_assessments"] = {
"naming_quality": {"score": 82, "source": "holistic"}
}
@@ -295,7 +354,7 @@ def test_resolve_non_review_issue_does_not_mark_stale():
confidence="high",
summary="unused name",
)
- state["issues"] = {issue["id"]: issue}
+ state["work_items"] = {issue["id"]: issue}
state["subjective_assessments"] = {
"naming_quality": {"score": 82, "source": "holistic"}
}
@@ -325,7 +384,7 @@ def test_resolve_wontfix_captures_snapshot_metadata():
summary="large module",
detail={"loc": 210, "complexity_score": 42},
)
- state["issues"] = {issue["id"]: issue}
+ state["work_items"] = {issue["id"]: issue}
resolution_mod.resolve_issues(
state,
@@ -335,7 +394,7 @@ def test_resolve_wontfix_captures_snapshot_metadata():
attestation="I have actually reviewed this and I am not gaming the score.",
)
- resolved = state["issues"][issue["id"]]
+ resolved = state["work_items"][issue["id"]]
assert resolved["status"] == "wontfix"
assert resolved["wontfix_scan_count"] == 17
assert resolved["wontfix_snapshot"]["scan_count"] == 17
@@ -370,7 +429,7 @@ def test_resolve_stale_wontfix_refreshes_original_wontfix_snapshot():
summary="stale wontfix",
detail={"original_issue_id": original["id"], "reasons": ["scan_decay"]},
)
- state["issues"] = {
+ state["work_items"] = {
original["id"]: original,
stale["id"]: stale,
}
@@ -383,7 +442,7 @@ def test_resolve_stale_wontfix_refreshes_original_wontfix_snapshot():
attestation="I have actually re-reviewed this wontfix and I am not gaming the score.",
)
- refreshed = state["issues"][original["id"]]
+ refreshed = state["work_items"][original["id"]]
assert refreshed["status"] == "wontfix"
assert refreshed["wontfix_scan_count"] == 24
assert refreshed["wontfix_snapshot"]["scan_count"] == 24
@@ -406,7 +465,7 @@ def test_resolve_open_reopens_non_open_issue_and_increments_reopen_count():
issue["resolved_at"] = "2026-01-01T10:00:00+00:00"
issue["note"] = "fixed earlier"
issue["reopen_count"] = 2
- state["issues"] = {issue["id"]: issue}
+ state["work_items"] = {issue["id"]: issue}
resolved_ids = resolution_mod.resolve_issues(
state,
@@ -417,7 +476,7 @@ def test_resolve_open_reopens_non_open_issue_and_increments_reopen_count():
)
assert resolved_ids == [issue["id"]]
- reopened = state["issues"][issue["id"]]
+ reopened = state["work_items"][issue["id"]]
assert reopened["status"] == "open"
assert reopened["resolved_at"] is None
assert reopened["note"] == "needs deeper fix"
diff --git a/desloppify/tests/state/test_suppression_scoring.py b/desloppify/tests/state/test_suppression_scoring.py
index e4eed052..5d0eadcc 100644
--- a/desloppify/tests/state/test_suppression_scoring.py
+++ b/desloppify/tests/state/test_suppression_scoring.py
@@ -163,7 +163,7 @@ def test_fixed_stays_fixed(self):
state = _minimal_state(issues)
removed = remove_ignored_issues(state, "src/a.ts")
assert removed == 1
- f = state["issues"]["unused::src/a.ts::foo"]
+ f = state["work_items"]["unused::src/a.ts::foo"]
assert f["suppressed"] is True
assert f["status"] == "fixed" # NOT reopened to "open"
@@ -177,7 +177,7 @@ def test_auto_resolved_stays_auto_resolved(self):
}
state = _minimal_state(issues)
remove_ignored_issues(state, "src/a.ts")
- f = state["issues"]["unused::src/a.ts::bar"]
+ f = state["work_items"]["unused::src/a.ts::bar"]
assert f["suppressed"] is True
assert f["status"] == "auto_resolved"
@@ -191,7 +191,7 @@ def test_false_positive_stays_false_positive(self):
}
state = _minimal_state(issues)
remove_ignored_issues(state, "src/a.ts")
- f = state["issues"]["unused::src/a.ts::baz"]
+ f = state["work_items"]["unused::src/a.ts::baz"]
assert f["suppressed"] is True
assert f["status"] == "false_positive"
@@ -218,15 +218,15 @@ def test_directory_pattern_matches_descendants(self):
removed_worktrees = remove_ignored_issues(state, ".claude/worktrees")
assert removed_worktrees == 1
assert (
- state["issues"]["security::.claude/worktrees/a/file.py::b101"]["suppressed"]
+ state["work_items"]["security::.claude/worktrees/a/file.py::b101"]["suppressed"]
is True
)
- assert state["issues"]["security::.claude/file.py::b101"]["suppressed"] is False
+ assert state["work_items"]["security::.claude/file.py::b101"]["suppressed"] is False
removed_claude = remove_ignored_issues(state, ".claude")
assert removed_claude == 2
- assert state["issues"]["security::.claude/file.py::b101"]["suppressed"] is True
- assert state["issues"]["security::src/app.py::b101"]["suppressed"] is False
+ assert state["work_items"]["security::.claude/file.py::b101"]["suppressed"] is True
+ assert state["work_items"]["security::src/app.py::b101"]["suppressed"] is False
# ---------------------------------------------------------------------------
@@ -295,21 +295,21 @@ def test_suppressed_issues_invisible_to_scoring(self):
# Simulate ignore: suppress the issue
remove_ignored_issues(state, "src/a.ts")
- f = state["issues"]["unused::src/a.ts::foo"]
+ f = state["work_items"]["unused::src/a.ts::foo"]
assert f["suppressed"] is True
assert f["status"] == "fixed" # preserved
# _count_issues should not see it
- counters, _ = _count_issues(state["issues"])
+ counters, _ = _count_issues(state["work_items"])
assert counters.get("open", 0) == 0
assert counters.get("fixed", 0) == 0 # suppressed => invisible
# _iter_scoring_candidates should not yield it
candidates = list(
- _iter_scoring_candidates("unused", state["issues"], frozenset())
+ _iter_scoring_candidates("unused", state["work_items"], frozenset())
)
assert candidates == []
# open_scope_breakdown should not count it
- breakdown = open_scope_breakdown(state["issues"], ".")
+ breakdown = open_scope_breakdown(state["work_items"], ".")
assert breakdown["global"] == 0
diff --git a/desloppify/tests/workflows/test_tweet_release_script.py b/desloppify/tests/workflows/test_tweet_release_script.py
new file mode 100644
index 00000000..ca3cfdea
--- /dev/null
+++ b/desloppify/tests/workflows/test_tweet_release_script.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from uuid import uuid4
+
+import pytest
+
+
+SCRIPT_PATH = (
+ Path(__file__).resolve().parents[3]
+ / ".github"
+ / "workflows"
+ / "scripts"
+ / "tweet_release.py"
+)
+
+
+def _load_tweet_release_module(monkeypatch: pytest.MonkeyPatch):
+ anthropic_stub = SimpleNamespace(Anthropic=lambda: SimpleNamespace(messages=None))
+ tweepy_stub = SimpleNamespace(
+ OAuth1UserHandler=lambda *args, **kwargs: None,
+ API=lambda auth: None,
+ Client=lambda **kwargs: None,
+ errors=SimpleNamespace(TwitterServerError=RuntimeError),
+ )
+ _RequestException = type("RequestException", (OSError,), {})
+ requests_stub = SimpleNamespace(
+ get=lambda *args, **kwargs: None,
+ post=lambda *args, **kwargs: None,
+ RequestException=_RequestException,
+ ConnectionError=type("ConnectionError", (_RequestException,), {}),
+ Timeout=type("Timeout", (_RequestException,), {}),
+ HTTPError=type("HTTPError", (_RequestException,), {}),
+ exceptions=SimpleNamespace(RequestException=_RequestException),
+ )
+ monkeypatch.setitem(sys.modules, "anthropic", anthropic_stub)
+ monkeypatch.setitem(sys.modules, "tweepy", tweepy_stub)
+ monkeypatch.setitem(sys.modules, "requests", requests_stub)
+
+ module_name = f"tweet_release_test_{uuid4().hex}"
+ spec = importlib.util.spec_from_file_location(module_name, SCRIPT_PATH)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_generate_image_uses_timeout_and_wraps_request_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+ seen: dict[str, object] = {}
+
+ def fake_post(*args, **kwargs):
+ seen["timeout"] = kwargs["timeout"]
+ raise module.requests.Timeout("too slow")
+
+ monkeypatch.setattr(module.requests, "post", fake_post)
+
+ with pytest.raises(module.ReleaseTweetError, match="fal.ai request failed"):
+ module.generate_image("prompt", "key")
+
+ assert seen["timeout"] == module.REQUEST_TIMEOUT_SECONDS
+
+
+def test_generate_tweet_and_prompt_wraps_bad_claude_payload(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+
+ class _Messages:
+ @staticmethod
+ def create(**_kwargs):
+ return SimpleNamespace(content=[SimpleNamespace(text="{not json}")])
+
+ monkeypatch.setattr(
+ module.anthropic,
+ "Anthropic",
+ lambda: SimpleNamespace(messages=_Messages()),
+ )
+
+ with pytest.raises(module.ReleaseTweetError, match="Anthropic returned invalid JSON payload"):
+ module.generate_tweet_and_prompt("v1.2.3", ["Feature"], "https://example.com/release")
+
+
+def test_download_image_uses_timeout_and_wraps_network_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+ seen: dict[str, object] = {}
+
+ def fake_get(*args, **kwargs):
+ seen["timeout"] = kwargs["timeout"]
+ raise module.requests.ConnectionError("network down")
+
+ monkeypatch.setattr(module.requests, "get", fake_get)
+
+ with pytest.raises(module.ReleaseTweetError, match="image download failed"):
+ module.download_image("https://example.com/image.png")
+
+ assert seen["timeout"] == module.REQUEST_TIMEOUT_SECONDS
+
+
+def test_main_posts_trimmed_tweet_and_cleans_up(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+ image_path = tmp_path / "release.png"
+ image_path.write_bytes(b"image")
+ posted: dict[str, str] = {}
+
+ monkeypatch.setenv("RELEASE_TAG", "v1.2.3")
+ monkeypatch.setenv("RELEASE_BODY", "## First\n## Second")
+ monkeypatch.setenv("RELEASE_URL", "https://example.com/release")
+ monkeypatch.setenv("FAL_KEY", "fal-key")
+ monkeypatch.setattr(
+ module,
+ "generate_tweet_and_prompt",
+ lambda *_args: {
+ "tweet": "Introducing desloppify v1.2.3!\n" + "\n".join("- feature" for _ in range(80)),
+ "image_prompt": "draw a release board",
+ },
+ )
+ monkeypatch.setattr(module, "generate_image", lambda *_args: "https://example.com/img.png")
+ monkeypatch.setattr(module, "download_image", lambda *_args: str(image_path))
+
+ def fake_post(tweet_text: str, image_file: str, reply_text: str) -> None:
+ posted["tweet"] = tweet_text
+ posted["image"] = image_file
+ posted["reply"] = reply_text
+
+ monkeypatch.setattr(module, "post_tweet_with_reply", fake_post)
+
+ module.main()
+
+ assert posted["image"] == str(image_path)
+ assert len(posted["tweet"]) <= 280
+ assert posted["reply"] == "Release notes: https://example.com/release"
+ assert not image_path.exists()
+
+
+def test_post_tweet_with_reply_wraps_media_upload_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+
+ class _Api:
+ @staticmethod
+ def media_upload(_image_path):
+ raise RuntimeError("upload boom")
+
+ monkeypatch.setenv("TWITTER_API_KEY", "key")
+ monkeypatch.setenv("TWITTER_API_SECRET", "secret")
+ monkeypatch.setenv("TWITTER_ACCESS_TOKEN", "token")
+ monkeypatch.setenv("TWITTER_ACCESS_SECRET", "access-secret")
+ monkeypatch.setattr(module.tweepy, "API", lambda _auth: _Api())
+
+ with pytest.raises(module.ReleaseTweetError, match="Twitter media upload failed: upload boom"):
+ module.post_tweet_with_reply("tweet", "/tmp/image.png", "reply")
+
+
+def test_post_tweet_with_reply_wraps_non_retryable_create_tweet_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+
+ class _Media:
+ media_id = "m1"
+
+ class _Api:
+ @staticmethod
+ def media_upload(_image_path):
+ return _Media()
+
+ class _Client:
+ @staticmethod
+ def create_tweet(**_kwargs):
+ raise RuntimeError("tweet boom")
+
+ monkeypatch.setenv("TWITTER_API_KEY", "key")
+ monkeypatch.setenv("TWITTER_API_SECRET", "secret")
+ monkeypatch.setenv("TWITTER_ACCESS_TOKEN", "token")
+ monkeypatch.setenv("TWITTER_ACCESS_SECRET", "access-secret")
+ monkeypatch.setattr(
+ module.tweepy,
+ "errors",
+ SimpleNamespace(TwitterServerError=type("TwitterServerError", (Exception,), {})),
+ )
+ monkeypatch.setattr(module.tweepy, "API", lambda _auth: _Api())
+ monkeypatch.setattr(module.tweepy, "Client", lambda **_kwargs: _Client())
+
+ with pytest.raises(module.ReleaseTweetError, match="Twitter create_tweet failed: tweet boom"):
+ module.post_tweet_with_reply("tweet", "/tmp/image.png", "reply")
+
+
+def test_main_exits_cleanly_on_bounded_release_failure(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ module = _load_tweet_release_module(monkeypatch)
+
+ monkeypatch.setenv("RELEASE_TAG", "v1.2.3")
+ monkeypatch.setenv("RELEASE_BODY", "## First")
+ monkeypatch.setenv("RELEASE_URL", "https://example.com/release")
+ monkeypatch.setenv("FAL_KEY", "fal-key")
+ monkeypatch.setattr(
+ module,
+ "generate_tweet_and_prompt",
+ lambda *_args: {
+ "tweet": "Introducing desloppify v1.2.3!",
+ "image_prompt": "draw a release board",
+ },
+ )
+ monkeypatch.setattr(
+ module,
+ "generate_image",
+ lambda *_args: (_ for _ in ()).throw(module.ReleaseTweetError("fal.ai request failed")),
+ )
+
+ with pytest.raises(SystemExit) as exc_info:
+ module.main()
+
+ assert exc_info.value.code == 1
+ assert "Release tweet failed: fal.ai request failed" in capsys.readouterr().err
diff --git a/docs/DEVELOPMENT_PHILOSOPHY.md b/docs/DEVELOPMENT_PHILOSOPHY.md
index 6f466506..b4dca4f1 100644
--- a/docs/DEVELOPMENT_PHILOSOPHY.md
+++ b/docs/DEVELOPMENT_PHILOSOPHY.md
@@ -32,7 +32,7 @@ This is the thing we care about most. If an agent can game the score to 100 with
## Language-agnostic
-The scoring model and the core engine don't know about any specific language. Language-specific stuff lives in plugins. The principles and scoring intent stay the same whether you're scanning TypeScript, Python, or Rust. Currently 28 languages, and the plugin framework makes adding more straightforward.
+The scoring model and the core engine don't know about any specific language. Language-specific stuff lives in plugins. The principles and scoring intent stay the same whether you're scanning TypeScript, Python, or Rust. Currently 29 languages, and the plugin framework makes adding more straightforward.
## Architectural boundaries
diff --git a/docs/SKILL.md b/docs/SKILL.md
index 1d07dd1d..704fe88f 100644
--- a/docs/SKILL.md
+++ b/docs/SKILL.md
@@ -5,7 +5,7 @@ description: >
about code quality, technical debt, dead code, large files, god classes,
duplicate functions, code smells, naming issues, import cycles, or coupling
problems. Also use when asked for a health score, what to fix next, or to
- create a cleanup plan. Supports 28 languages.
+ create a cleanup plan. Supports 29 languages.
allowed-tools: Bash(desloppify *)
---
diff --git a/pyproject.toml b/pyproject.toml
index e27d6e6d..f5d4ebe2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "desloppify"
-version = "0.9.8"
+version = "0.9.9"
description = "Multi-language codebase health scanner and technical debt tracker"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/test_release_image.png b/test_release_image.png
new file mode 100644
index 00000000..6658c5b7
Binary files /dev/null and b/test_release_image.png differ
diff --git a/website/index.html b/website/index.html
index f9602a3a..3be29121 100644
--- a/website/index.html
+++ b/website/index.html
@@ -33,7 +33,7 @@ desloppify
Give your AI coding agent a north star. Desloppify scans, scores, and
systematically improves code quality — mechanical issues and subjective ones —
- across 28 languages. The score resists gaming. The only way up is to actually
+ across 29 languages. The score resists gaming. The only way up is to actually
make the code better.
@@ -43,7 +43,7 @@
desloppify