diff --git a/desloppify/engine/_work_queue/ranking.py b/desloppify/engine/_work_queue/ranking.py index 851a5676d..8129e5d5e 100644 --- a/desloppify/engine/_work_queue/ranking.py +++ b/desloppify/engine/_work_queue/ranking.py @@ -32,6 +32,7 @@ from desloppify.engine._work_queue.synthetic import subjective_strict_scores from desloppify.engine._work_queue.types import WorkQueueItem from desloppify.engine.planning.helpers import CONFIDENCE_ORDER +from desloppify.engine.policy.zones import should_skip_detector_in_zone logger = logging.getLogger(__name__) @@ -140,6 +141,8 @@ def build_issue_items( continue if not status_matches(issue.get("status", "open"), status_filter): continue + if should_skip_detector_in_zone(issue.get("zone"), issue.get("detector")): + continue if chronic and not ( issue.get("status") == "open" and issue.get("reopen_count", 0) >= 2 ): diff --git a/desloppify/engine/planning/scan.py b/desloppify/engine/planning/scan.py index 656a0cf22..fd46d8d92 100644 --- a/desloppify/engine/planning/scan.py +++ b/desloppify/engine/planning/scan.py @@ -7,18 +7,22 @@ from pathlib import Path from desloppify.base.discovery.file_paths import rel -from desloppify.base.output.terminal import colorize from desloppify.base.discovery.paths import get_project_root +from desloppify.base.output.terminal import colorize from desloppify.engine.planning.helpers import is_subjective_phase -from desloppify.engine.policy.zones import ZONE_POLICIES, FileZoneMap +from desloppify.engine.policy.zones import ( + ZONE_POLICIES, + FileZoneMap, + should_skip_issue, +) from desloppify.languages.framework import ( - clear_review_phase_prefetch, DetectorPhase, LangConfig, LangRun, auto_detect_lang, available_langs, capability_report, + clear_review_phase_prefetch, get_lang, make_lang_run, prewarm_review_phase_detectors, @@ -121,6 +125,31 @@ def _stamp_issue_context(issues: list[Issue], lang: LangRun) -> None: issue["confidence"] = "low" +def _filter_zone_skipped_issues(issues: list[Issue], lang: LangRun) -> list[Issue]: + """Apply zone policy once to every normalized phase result. + + Individual phases often filter their raw entries, but raw entry shapes + differ (for example, smell findings group matches across files). A final + normalized-issue gate prevents a phase omission from admitting generated, + vendor, or otherwise policy-skipped findings into persistent state. + """ + if lang.zone_map is None: + return issues + + filtered: list[Issue] = [] + for issue in issues: + filepath = issue.get("file") + detector = issue.get("detector") + if ( + isinstance(filepath, str) + and isinstance(detector, str) + and should_skip_issue(lang.zone_map, filepath, detector) + ): + continue + filtered.append(issue) + return filtered + + def _generate_issues_from_lang( path: Path, lang: LangRun, @@ -137,6 +166,7 @@ def _generate_issues_from_lang( issues, all_potentials = _run_phases(path, lang, phases) finally: clear_review_phase_prefetch(lang) + issues = _filter_zone_skipped_issues(issues, lang) _stamp_issue_context(issues, lang) _stderr(f"\n Total: {len(issues)} issues") return issues, all_potentials diff --git a/desloppify/engine/policy/zones.py b/desloppify/engine/policy/zones.py index 16e524524..1431feb9b 100644 --- a/desloppify/engine/policy/zones.py +++ b/desloppify/engine/policy/zones.py @@ -293,18 +293,28 @@ def adjust_potential(zone_map, total: int) -> int: return max(total - zone_map.non_production_count(), 0) -def should_skip_issue(zone_map, filepath: str, detector: str) -> bool: - """Check if a issue should be skipped based on zone policy. +def should_skip_detector_in_zone(zone: object, detector: object) -> bool: + """Return whether a detector is excluded by an issue's stored zone. - Returns True if the file's zone policy says to skip this detector. + Queue consumers only have the zone stamped onto an issue, not the source + ``FileZoneMap`` used during scanning. Keep their policy decision aligned + with phase-time filtering rather than treating every non-production zone + as non-actionable. """ - if zone_map is None: + normalized_zone = normalize_zone(zone) + if normalized_zone is None or not isinstance(detector, str): return False - zone = zone_map.get(filepath) - policy = ZONE_POLICIES.get(zone) + policy = ZONE_POLICIES.get(normalized_zone) return policy is not None and detector in policy.skip_detectors +def should_skip_issue(zone_map, filepath: str, detector: str) -> bool: + """Check whether a file's zone policy skips its detector.""" + if zone_map is None: + return False + return should_skip_detector_in_zone(zone_map.get(filepath), detector) + + def filter_entries( zone_map, entries: list[dict], detector: str, file_key: str = "file" ) -> list[dict]: diff --git a/desloppify/tests/commands/test_queue_count_consistency.py b/desloppify/tests/commands/test_queue_count_consistency.py index 13f6786eb..216d2821b 100644 --- a/desloppify/tests/commands/test_queue_count_consistency.py +++ b/desloppify/tests/commands/test_queue_count_consistency.py @@ -14,6 +14,7 @@ from desloppify.engine._plan.policy.subjective import compute_subjective_visibility from desloppify.engine._state.merge_issues import verify_disappeared +from desloppify.engine.policy.zones import FileZoneMap, Zone, ZoneRule # --------------------------------------------------------------------------- # Helpers @@ -171,6 +172,37 @@ def test_out_of_scope_verification_adds_to_resolved_detectors(self): ) assert "smells" in detectors + def test_generated_zone_issue_auto_resolves_when_absent(self): + """A scan clears historical issues that current zone policy excludes.""" + generated_file = "client/migrations/versions/revision.py" + existing = { + "generated-smells": { + "id": "generated-smells", + "status": "open", + "file": generated_file, + "detector": "smells", + }, + } + zone_map = FileZoneMap( + [generated_file], [ZoneRule(Zone.GENERATED, ["/migrations/"])] + ) + + resolved, _lang, out_of_scope, detectors = verify_disappeared( + existing, + current_ids=set(), + suspect_detectors=set(), + now="2026-08-01T00:00:00+00:00", + lang=None, + scan_path=".", + zone_map=zone_map, + ) + + assert resolved == 1 + assert out_of_scope == 0 + assert detectors == {"smells"} + assert existing["generated-smells"]["status"] == "auto_resolved" + assert "zone policy now skips smells" in existing["generated-smells"]["note"] + # --------------------------------------------------------------------------- # Fix 2: queue counting functions pass scan_path @@ -238,6 +270,74 @@ def test_queue_count_no_scan_path_returns_all(self): ) assert result["total"] == 2 + def test_queue_hides_historical_zone_policy_skipped_issues(self): + """Stored generated findings cannot re-enter the active queue.""" + from desloppify.engine._work_queue.core import ( + QueueBuildOptions, + build_work_queue, + ) + + state: dict = { + "issues": { + "generated-smells": { + "id": "generated-smells", + "detector": "smells", + "status": "open", + "file": "client/migrations/versions/revision.py", + "zone": "generated", + "tier": 1, + "confidence": "high", + "summary": "historical generated smell", + "detail": {}, + }, + "generated-structural": { + "id": "generated-structural", + "detector": "structural", + "status": "open", + "file": "client/migrations/versions/revision.py", + "zone": "generated", + "tier": 1, + "confidence": "high", + "summary": "historical generated structural finding", + "detail": {}, + }, + "test-unused": { + "id": "test-unused", + "detector": "unused", + "status": "open", + "file": "tests/test_live.py", + "zone": "test", + "tier": 1, + "confidence": "high", + "summary": "allowed test-zone detector", + "detail": {}, + }, + "production-smells": { + "id": "production-smells", + "detector": "smells", + "status": "open", + "file": "src/live.py", + "zone": "production", + "tier": 1, + "confidence": "high", + "summary": "production finding", + "detail": {}, + }, + }, + "scan_count": 5, + } + + result = build_work_queue( + state, + options=QueueBuildOptions(status="open", count=None), + ) + + assert {item["id"] for item in result["items"]} == { + "test-unused", + "production-smells", + } + assert result["total"] == 2 + def test_explicit_scan_path_overrides_state(self): """Explicit scan_path on QueueBuildOptions overrides state value.""" from desloppify.engine._work_queue.core import ( @@ -772,11 +872,11 @@ class TestQueueGuardScanPath: def test_queue_guard_respects_scan_path_from_state(self): """_check_queue_order_guard uses build_work_queue which auto-reads scan_path from state, so out-of-scope items don't appear in the queue.""" - from desloppify.app.commands.resolve.queue_guard import _check_queue_order_guard from desloppify.app.commands.resolve.plan_load import ( DegradedPlanWarningState, ResolvePlanAccess, ) + from desloppify.app.commands.resolve.queue_guard import _check_queue_order_guard state = { "issues": { diff --git a/desloppify/tests/plan/test_plan_modules_direct.py b/desloppify/tests/plan/test_plan_modules_direct.py index 90db1bf33..37d20ab05 100644 --- a/desloppify/tests/plan/test_plan_modules_direct.py +++ b/desloppify/tests/plan/test_plan_modules_direct.py @@ -8,11 +8,12 @@ import pytest import desloppify.engine._state.filtering as filtering_mod -from desloppify.engine._work_queue.core import QueueBuildOptions import desloppify.engine.planning.helpers as plan_common_mod import desloppify.engine.planning.queue_policy as queue_policy_mod import desloppify.engine.planning.scan as plan_scan_mod import desloppify.engine.planning.select as plan_select_mod +from desloppify.engine._work_queue.core import QueueBuildOptions +from desloppify.engine.policy.zones import FileZoneMap, Zone, ZoneRule class _Phase: @@ -119,6 +120,72 @@ def test_generate_issues_from_lang_clears_prefetch_on_phase_error(monkeypatch): assert calls == ["prime", "clear"] +def test_generate_issues_from_lang_filters_zone_skipped_phase_output(monkeypatch): + """A final gate catches generated findings leaked by an individual phase.""" + generated_file = "client/migrations/versions/revision.py" + production_file = "src/live.py" + test_file = "tests/test_live.py" + lang = SimpleNamespace( + phases=[], + name="python", + zone_map=FileZoneMap( + [generated_file, production_file, test_file], + [ + ZoneRule(Zone.GENERATED, ["/migrations/"]), + ZoneRule(Zone.TEST, ["/tests/"]), + ], + ), + ) + + monkeypatch.setattr(plan_scan_mod, "_build_zone_map", lambda *_a, **_k: None) + monkeypatch.setattr(plan_scan_mod, "_select_phases", lambda *_a, **_k: []) + monkeypatch.setattr( + plan_scan_mod, + "_run_phases", + lambda *_a, **_k: ( + [ + { + "id": "generated-smells", + "file": generated_file, + "detector": "smells", + }, + { + "id": "generated-structural", + "file": generated_file, + "detector": "structural", + }, + { + "id": "production-smells", + "file": production_file, + "detector": "smells", + }, + { + "id": "allowed-test-unused", + "file": test_file, + "detector": "unused", + }, + ], + {"smells": 2, "structural": 1, "unused": 1}, + ), + ) + monkeypatch.setattr( + plan_scan_mod, "prewarm_review_phase_detectors", lambda *_a, **_k: None + ) + monkeypatch.setattr( + plan_scan_mod, "clear_review_phase_prefetch", lambda *_a, **_k: None + ) + + issues, potentials = plan_scan_mod._generate_issues_from_lang(Path("."), lang) + + assert [issue["id"] for issue in issues] == [ + "production-smells", + "allowed-test-unused", + ] + assert [issue["zone"] for issue in issues] == ["production", "test"] + assert all(issue["lang"] == "python" for issue in issues) + assert potentials == {"smells": 2, "structural": 1, "unused": 1} + + def test_resolve_lang_prefers_explicit_and_fallbacks(monkeypatch): explicit = object() assert plan_scan_mod._resolve_lang(explicit, Path(".")) is explicit