diff --git a/desloppify/engine/planning/scan.py b/desloppify/engine/planning/scan.py index 656a0cf22..7e16454cf 100644 --- a/desloppify/engine/planning/scan.py +++ b/desloppify/engine/planning/scan.py @@ -7,18 +7,18 @@ 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.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, @@ -54,7 +54,9 @@ def _resolve_lang( return get_lang(detected) -def _build_zone_map(path: Path, lang: LangRun, zone_overrides: dict[str, str] | None) -> None: +def _build_zone_map( + path: Path, lang: LangRun, zone_overrides: dict[str, str] | None +) -> None: if not (lang.zone_rules and lang.file_finder): return @@ -77,7 +79,9 @@ def _build_zone_map(path: Path, lang: LangRun, zone_overrides: dict[str, str] | _stderr(f" Not available: {', '.join(missing)}") -def _select_phases(lang: LangRun, *, include_slow: bool, profile: str) -> list[DetectorPhase]: +def _select_phases( + lang: LangRun, *, include_slow: bool, profile: str +) -> list[DetectorPhase]: active_profile = profile if profile in {"objective", "full", "ci"} else "full" phases = lang.phases if not include_slow or active_profile == "ci": @@ -87,7 +91,9 @@ def _select_phases(lang: LangRun, *, include_slow: bool, profile: str) -> list[D return phases -def _run_phases(path: Path, lang: LangRun, phases: list[DetectorPhase]) -> tuple[list[Issue], dict[str, int]]: +def _run_phases( + path: Path, lang: LangRun, phases: list[DetectorPhase] +) -> tuple[list[Issue], dict[str, int]]: issues: list[Issue] = [] all_potentials: dict[str, int] = {} @@ -114,7 +120,11 @@ def _stamp_issue_context(issues: list[Issue], lang: LangRun) -> None: if lang.zone_map is None: continue - zone = lang.zone_map.get(issue.get("file", "")) + file_path = issue.get("file", "") + if issue.get("detector") == "flat_dirs": + zone = lang.zone_map.get_directory(file_path) + else: + zone = lang.zone_map.get(file_path) issue["zone"] = zone.value policy = zone_policies.get(zone) if zone_policies else None if policy and issue.get("detector") in policy.downgrade_detectors: diff --git a/desloppify/engine/policy/zones.py b/desloppify/engine/policy/zones.py index 16e524524..f7bab3120 100644 --- a/desloppify/engine/policy/zones.py +++ b/desloppify/engine/policy/zones.py @@ -11,6 +11,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum +from pathlib import PurePath from desloppify.base.output.fallbacks import log_best_effort_failure from desloppify.engine.policy.zones_data import ( @@ -194,6 +195,25 @@ def get(self, path: str) -> Zone: return Zone.PRODUCTION + def get_directory(self, path: str) -> Zone: + """Get the common zone for classified files beneath a directory.""" + rel_path = path + if self._rel_fn is not None: + try: + rel_path = self._rel_fn(path) + except (OSError, TypeError, ValueError): + pass + + directory = PurePath(rel_path) + zones = { + zone + for file_path, zone in self._rel_map.items() + if PurePath(file_path).is_relative_to(directory) + } + if len(zones) == 1: + return zones.pop() + return Zone.PRODUCTION + def exclude(self, files: list[str], *zones: Zone) -> list[str]: """Return files NOT in the given zones.""" zone_set = set(zones) diff --git a/desloppify/tests/detectors/test_zones.py b/desloppify/tests/detectors/test_zones.py index c69e7c4b8..836ebe958 100644 --- a/desloppify/tests/detectors/test_zones.py +++ b/desloppify/tests/detectors/test_zones.py @@ -278,6 +278,23 @@ def test_get_unknown_file_defaults_production(self, zone_map): """File not in the map returns PRODUCTION.""" assert zone_map.get("unknown/file.py") == Zone.PRODUCTION + def test_get_directory_returns_common_descendant_zone(self): + """A directory inherits the zone shared by all classified descendants.""" + files = ["auth_test.go", "internal/runner/runner.go"] + overrides = {file_path: "test" for file_path in files} + zone_map = FileZoneMap(files, [], overrides=overrides) + + assert zone_map.get_directory(".") == Zone.TEST + assert zone_map.get_directory("internal") == Zone.TEST + + def test_get_directory_defaults_production_for_mixed_or_unknown_directory(self): + """Mixed and unknown directories retain conservative production scoring.""" + files = ["src/app.py", "tests/test_app.py"] + zone_map = FileZoneMap(files, COMMON_ZONE_RULES) + + assert zone_map.get_directory(".") == Zone.PRODUCTION + assert zone_map.get_directory("missing") == Zone.PRODUCTION + def test_exclude_zones(self, zone_map, sample_files): """exclude() removes files in specified zones.""" result = zone_map.exclude(sample_files, Zone.TEST, Zone.VENDOR) @@ -569,4 +586,3 @@ def test_every_zone_has_policy(self): # ── adjust_potential() ─────────────────────────────────────── - diff --git a/desloppify/tests/plan/test_plan_modules_direct.py b/desloppify/tests/plan/test_plan_modules_direct.py index 90db1bf33..6a66bc16b 100644 --- a/desloppify/tests/plan/test_plan_modules_direct.py +++ b/desloppify/tests/plan/test_plan_modules_direct.py @@ -8,11 +8,13 @@ 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._scoring.detection import detector_pass_rate +from desloppify.engine._work_queue.core import QueueBuildOptions +from desloppify.engine.policy.zones import FileZoneMap class _Phase: @@ -65,6 +67,52 @@ def test_select_phases_and_run_phases_behavior(): assert potentials == {"fast": 1, "slow": 2, "review": 3} +def test_stamp_issue_context_excludes_all_test_flat_directory_from_score(): + files = ["auth_test.go", "internal/runner/runner.go"] + overrides = {file_path: "test" for file_path in files} + zone_map = FileZoneMap(files, [], overrides=overrides) + issue = filtering_mod.make_issue( + "flat_dirs", + ".", + "", + tier=3, + confidence="medium", + summary="Directory overload", + ) + lang = SimpleNamespace(name="go", zone_map=zone_map) + + plan_scan_mod._stamp_issue_context([issue], lang) + + assert issue["zone"] == "test" + assert detector_pass_rate("flat_dirs", {issue["id"]: issue}, 5) == ( + 1.0, + 0, + 0.0, + ) + + +def test_stamp_issue_context_keeps_production_flat_directory_scored(): + zone_map = FileZoneMap(["app.go", "internal/service.go"], []) + issue = filtering_mod.make_issue( + "flat_dirs", + ".", + "", + tier=3, + confidence="medium", + summary="Directory overload", + ) + lang = SimpleNamespace(name="go", zone_map=zone_map) + + plan_scan_mod._stamp_issue_context([issue], lang) + + assert issue["zone"] == "production" + assert detector_pass_rate("flat_dirs", {issue["id"]: issue}, 5) == ( + 0.86, + 1, + 0.7, + ) + + def test_generate_issues_from_lang_primes_and_clears_review_prefetch(monkeypatch): calls: list[str] = [] lang = SimpleNamespace(phases=[], zone_map=None, name="python") @@ -124,7 +172,9 @@ def test_resolve_lang_prefers_explicit_and_fallbacks(monkeypatch): assert plan_scan_mod._resolve_lang(explicit, Path(".")) is explicit monkeypatch.setattr(plan_scan_mod, "auto_detect_lang", lambda _root: None) - monkeypatch.setattr(plan_scan_mod, "available_langs", lambda: ["python", "typescript"]) + monkeypatch.setattr( + plan_scan_mod, "available_langs", lambda: ["python", "typescript"] + ) monkeypatch.setattr(plan_scan_mod, "get_lang", lambda name: f"cfg:{name}") resolved = plan_scan_mod._resolve_lang(None, Path(".")) assert resolved == "cfg:python"