Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions desloppify/engine/detectors/coupling.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ def _rel_to_root(value: str, root_norm: str) -> str:
"""Return root-relative variant (best effort) for path/prefix matching."""
if value.startswith(root_norm):
return value[len(root_norm) :]
# On Windows, root_norm may be drive-rooted ("C:/project/") after
# Path.resolve(), while the caller still passes POSIX-rooted paths
# ("/project/..."). Strip the drive letter from root_norm and retry so
# tests and tools that mix the two shapes still match.
if len(root_norm) > 3 and root_norm[1:3] == ":/":
root_no_drive = root_norm[2:]
if value.startswith(root_no_drive):
return value[len(root_no_drive) :]
return value.lstrip("/")


Expand Down
12 changes: 8 additions & 4 deletions desloppify/engine/detectors/coverage/mapping_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ def _build_prod_by_module(
project_root: str,
) -> dict[str, str]:
"""Build module lookup map for production files."""
root_str = project_root + os.sep
# Normalize to POSIX-style for the prefix comparison so mixed-slash inputs
# (Windows root + forward-slash relative segments from other tools) match.
root_posix = project_root.replace(os.sep, "/")
root_str = root_posix + "/"
prod_by_module: dict[str, str] = {}
ambiguous_aliases: set[str] = set()

Expand All @@ -154,10 +157,11 @@ def assign_alias(alias: str, prod_file: str) -> None:
prod_by_module.pop(alias, None)

for prod_file in production_files:
normalized_prod = prod_file.replace(os.sep, "/")
rel_path = (
prod_file[len(root_str) :]
if prod_file.startswith(root_str)
else prod_file
normalized_prod[len(root_str) :]
if normalized_prod.startswith(root_str)
else normalized_prod
)
# Strip src/ prefix so src-layout projects map correctly
# (e.g. 'src/argos_toolkit/foo.py' -> 'argos_toolkit.foo')
Expand Down
37 changes: 30 additions & 7 deletions desloppify/engine/detectors/test_coverage/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,34 @@


def _normalize_graph_paths(graph: dict) -> dict:
"""Normalize graph paths to relative paths."""
root_prefix = str(get_project_root()) + os.sep
"""Normalize graph paths to relative paths.

def _to_rel(path: str) -> str:
return path[len(root_prefix) :] if path.startswith(root_prefix) else path
Comparison is done in POSIX form (forward slashes) so mixed-slash inputs
on Windows still match, but the returned keys preserve the input separator
convention by slicing from the original path string.
"""
root_native = str(get_project_root())
root_posix = root_native.replace(os.sep, "/")
root_prefix_native = root_native + os.sep
root_prefix_posix = root_posix + "/"

needs_norm = any(k.startswith(root_prefix) for k in graph)
def _to_rel(path: str) -> str:
# Prefer native-form trim so callers that pass purely native paths
# get back native-form relatives (preserves existing contract).
if path.startswith(root_prefix_native):
return path[len(root_prefix_native) :]
# Fall back to POSIX-form trim for callers that pass mixed-slash paths
# (e.g. Windows root + forward-slash relative from cargo/npm/etc.).
normalized = path.replace(os.sep, "/")
if normalized.startswith(root_prefix_posix):
return normalized[len(root_prefix_posix) :]
return path

needs_norm = any(
k.startswith(root_prefix_native)
or k.replace(os.sep, "/").startswith(root_prefix_posix)
for k in graph
)
if not needs_norm:
return graph

Expand All @@ -44,10 +65,12 @@ def _discover_scorable_and_tests(
extra_test_files: set[str] | None,
) -> tuple[set[str], set[str], set[str], int]:
"""Return (production_files, test_files, scorable_files, potential)."""
root_prefix = str(get_project_root()) + os.sep
root_posix = str(get_project_root()).replace(os.sep, "/")
root_prefix = root_posix + "/"

def _to_rel(path: str) -> str:
return path[len(root_prefix) :] if path.startswith(root_prefix) else path
normalized = path.replace(os.sep, "/")
return normalized[len(root_prefix) :] if normalized.startswith(root_prefix) else normalized

all_files = zone_map.all_files()
production_files = set(zone_map.include_only(all_files, Zone.PRODUCTION, Zone.SCRIPT))
Expand Down
6 changes: 6 additions & 0 deletions desloppify/engine/policy/zones.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ def _match_pattern(rel_path: str, pattern: str) -> bool:

See ZoneRule docstring for pattern type conventions.
"""
# Normalize separators so the rest of the pattern logic doesn't have to
# think about Windows backslashes. Patterns are documented as
# POSIX-style (e.g. "/tests/"); callers that pass native paths
# (rare, mostly in tests) should still classify correctly.
rel_path = rel_path.replace("\\", "/")
pattern = pattern.replace("\\", "/")
basename = os.path.basename(rel_path)

# Directory pattern: "/dir/" → substring on padded path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ def resolve_go_import(import_text: str, source_file: str, scan_path: str) -> str
return None

rel_path = import_text[len(module_path) :].lstrip("/")
candidate_dir = os.path.join(scan_path, rel_path)
# The import path uses forward slashes (Go module convention); on Windows,
# os.path.join with a forward-slashed segment produces mixed separators.
# Normalize so callers get native-form paths.
candidate_dir = os.path.normpath(os.path.join(scan_path, rel_path))
if os.path.isdir(candidate_dir):
for filename in sorted(os.listdir(candidate_dir)):
if filename.endswith(".go") and not filename.endswith("_test.go"):
Expand All @@ -44,15 +47,15 @@ def resolve_rust_import(import_text: str, source_file: str, scan_path: str) -> s
path_parts = parts[:-1] if len(parts) > 1 else parts
candidate = os.path.join(src_dir, *path_parts) + ".rs"
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)

candidate = os.path.join(src_dir, *path_parts, "mod.rs")
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)

candidate = os.path.join(src_dir, *parts) + ".rs"
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -70,7 +73,7 @@ def resolve_java_import(import_text: str, source_file: str, scan_path: str) -> s
for src_root in ["src/main/java", "src", "app/src/main/java", "."]:
candidate = os.path.join(scan_path, src_root, rel_path)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -89,7 +92,7 @@ def resolve_kotlin_import(import_text: str, source_file: str, scan_path: str) ->
for src_root in ["src/main/kotlin", "src/main/java", "src", "app/src/main/kotlin", "."]:
candidate = os.path.join(scan_path, src_root, rel_path)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -101,12 +104,12 @@ def resolve_cxx_include(import_text: str, source_file: str, scan_path: str) -> s
base = os.path.dirname(source_file)
candidate = os.path.normpath(os.path.join(base, import_text))
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)

for inc_dir in ["include", "src", "."]:
candidate = os.path.join(scan_path, inc_dir, import_text)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -122,10 +125,10 @@ def resolve_csharp_import(import_text: str, source_file: str, scan_path: str) ->
rel_path = os.path.join(*parts[:-1], filename)
candidate = os.path.join(scan_path, src_root, rel_path)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
candidate = os.path.join(scan_path, src_root, filename)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -137,7 +140,7 @@ def resolve_dart_import(import_text: str, source_file: str, scan_path: str) -> s
parts = import_text[len("package:") :].split("/", 1)
if len(parts) < 2:
return None
candidate = os.path.join(scan_path, "lib", parts[1])
candidate = os.path.normpath(os.path.join(scan_path, "lib", parts[1]))
return candidate if os.path.isfile(candidate) else None

base = os.path.dirname(source_file)
Expand All @@ -159,7 +162,7 @@ def resolve_scala_import(import_text: str, source_file: str, scan_path: str) ->
for src_root in ["src/main/scala", "src", "."]:
candidate = os.path.join(scan_path, src_root, rel_path)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None


Expand All @@ -185,5 +188,5 @@ def resolve_swift_import(import_text: str, source_file: str, scan_path: str) ->
continue
seen.add(candidate)
if os.path.isfile(candidate):
return candidate
return os.path.normpath(candidate)
return None
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,11 @@ def resolve_js_import(import_text: str, source_file: str, scan_path: str) -> str

base = os.path.dirname(source_file)
candidate = os.path.normpath(os.path.join(base, import_text))
# Concatenating "/index.js" onto a Windows path produces mixed separators
# ("C:\\...\\ui/index.js"); normalize each candidate so the returned path
# uses native separators throughout.
for ext in ("", ".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx"):
path = candidate + ext
path = os.path.normpath(candidate + ext)
if os.path.isfile(path):
return path
return None
Expand Down
3 changes: 3 additions & 0 deletions desloppify/languages/javascript/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ def resolve_import_spec(

base = os.path.dirname(test_path)
candidate = os.path.normpath(os.path.join(base, spec))
# production_files keys are POSIX-style (forward slashes). os.path.normpath
# produces backslashes on Windows, so normalize back to POSIX for lookup.
candidate = candidate.replace(os.sep, "/")
for ext in ("", ".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx"):
path = candidate + ext
if path in production_files:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1311,10 +1311,15 @@ def test_orchestrator_pipeline_entrypoint_is_exposed() -> None:


def test_orchestrator_pipeline_writes_exact_cli_helper(tmp_path: Path) -> None:
import sys
helper = orchestrator_pipeline_mod._write_desloppify_cli_helper(tmp_path)
text = helper.read_text(encoding="utf-8")
assert helper.exists()
assert helper.stat().st_mode & 0o111
# POSIX exec bits don't map onto Windows file modes (Windows uses ACLs);
# the helper is invoked via bash/PYTHONPATH wiring rather than direct exec
# so missing exec bits aren't a functional regression on Windows.
if sys.platform != "win32":
assert helper.stat().st_mode & 0o111
assert "PYTHONPATH=" in text
assert "-m desloppify.cli" in text

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ def test_rovodev_batch_command_includes_acli_rovodev_run_invocation(monkeypatch)
monkeypatch.delenv("DESLOPPIFY_ROVODEV_OUTPUT_SCHEMA", raising=False)
monkeypatch.delenv("DESLOPPIFY_ROVODEV_EXTRA_ARGS", raising=False)
monkeypatch.delenv("DESLOPPIFY_ROVODEV_EXECUTABLE", raising=False)
# On Windows, _resolve_executable wraps in `cmd /c` when the binary isn't on
# PATH, and _wrap_cmd_c then collapses the list — correct production
# behavior, but it obscures per-arg list shape. Stub to the unwrapped form
# so this test verifies command construction logic cross-platform.
monkeypatch.setattr(runner_rovodev_mod, "_resolve_executable", lambda name: [name])

cmd = runner_rovodev_mod.rovodev_batch_command(
prompt="hello world",
Expand All @@ -50,6 +55,7 @@ def test_rovodev_batch_command_honours_env_overrides(monkeypatch) -> None:
monkeypatch.setenv("DESLOPPIFY_ROVODEV_OUTPUT_SCHEMA", '{"type":"object"}')
monkeypatch.setenv("DESLOPPIFY_ROVODEV_EXTRA_ARGS", "--config-override foo")
monkeypatch.setenv("DESLOPPIFY_ROVODEV_EXECUTABLE", "acli")
monkeypatch.setattr(runner_rovodev_mod, "_resolve_executable", lambda name: [name])

cmd = runner_rovodev_mod.rovodev_batch_command(
prompt="prompt",
Expand All @@ -70,6 +76,7 @@ def test_rovodev_batch_command_no_yolo_opt_out(monkeypatch) -> None:
monkeypatch.setenv("DESLOPPIFY_ROVODEV_NO_YOLO", "1")
monkeypatch.delenv("DESLOPPIFY_ROVODEV_OUTPUT_SCHEMA", raising=False)
monkeypatch.delenv("DESLOPPIFY_ROVODEV_EXTRA_ARGS", raising=False)
monkeypatch.setattr(runner_rovodev_mod, "_resolve_executable", lambda name: [name])

cmd = runner_rovodev_mod.rovodev_batch_command(
prompt="p",
Expand Down
3 changes: 2 additions & 1 deletion desloppify/tests/commands/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ def test_state_path_from_explicit_state_arg():
args = SimpleNamespace(state="/custom/path.json", lang=None)
result = state_path(args)
assert result is not None
assert str(result) == "/custom/path.json"
# Use as_posix() to normalize separators on Windows.
assert result.as_posix() == "/custom/path.json"


def test_state_path_from_lang_arg():
Expand Down
6 changes: 5 additions & 1 deletion desloppify/tests/commands/test_runner_modules_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,18 @@ def fake_run(cmd, **kwargs):

def test_codex_batch_command_uses_sanitized_reasoning_effort(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("DESLOPPIFY_CODEX_REASONING_EFFORT", "HIGH")
# On Windows _resolve_executable wraps unresolvable names in `cmd /c`,
# then _wrap_cmd_c collapses the rest of the args into one string. That's
# correct production behavior but obscures per-arg list shape the test
# asserts. Patch to the unwrapped form so this test runs cross-platform.
monkeypatch.setattr(codex_batch_mod, "_resolve_executable", lambda name: [name])

command = codex_batch_mod.codex_batch_command(
prompt="review prompt",
repo_root=tmp_path,
output_file=tmp_path / "out.json",
)

# On Windows with .cmd wrappers, prefix may be ["cmd", "/c", "...codex.cmd"]
assert any(c.endswith("codex") or "codex" in c for c in command[:3])
assert "exec" in command
assert "--ephemeral" in command
Expand Down
15 changes: 12 additions & 3 deletions desloppify/tests/detectors/test_external_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ def test_returns_none_on_timeout(self, tmp_path):
):
assert detect_with_jscpd(tmp_path) is None

@pytest.mark.skipif(
not hasattr(__import__("os"), "getpgid"),
reason="os.getpgid is POSIX-only; the process-group kill path doesn't apply on Windows",
)
def test_timeout_kills_jscpd_process_group(self):
class FakeProc:
pid = 4321
Expand Down Expand Up @@ -963,11 +967,13 @@ def test_returns_absolute_paths(self, tmp_path):
assert all(p.startswith(str(tmp_path)) for p in result)

def test_includes_default_non_glob_entries(self, tmp_path):
import os
with patch(
"desloppify.base.discovery.source.get_exclusions", return_value=()
):
result = collect_exclude_dirs(tmp_path)
basenames = {p.rsplit("/", 1)[-1] for p in result}
# Use os.path.basename to be separator-agnostic (Windows uses \, POSIX /).
basenames = {os.path.basename(p) for p in result}
assert "node_modules" in basenames
assert "__pycache__" in basenames
assert ".git" in basenames
Expand All @@ -983,12 +989,13 @@ def test_excludes_glob_patterns(self, tmp_path):
assert not any("*" in p for p in result)

def test_includes_runtime_exclusions(self, tmp_path):
import os
with patch(
"desloppify.base.discovery.source.get_exclusions",
return_value=("vendor", "third_party"),
):
result = collect_exclude_dirs(tmp_path)
basenames = {p.rsplit("/", 1)[-1] for p in result}
basenames = {os.path.basename(p) for p in result}
assert "vendor" in basenames
assert "third_party" in basenames

Expand All @@ -1003,10 +1010,12 @@ def test_skips_runtime_glob_exclusions(self, tmp_path):

def test_deduplicates(self, tmp_path):
"""Runtime exclusion that overlaps with DEFAULT_EXCLUSIONS doesn't produce dupes."""
import os
with patch(
"desloppify.base.discovery.source.get_exclusions",
return_value=("node_modules",),
):
result = collect_exclude_dirs(tmp_path)
node_entries = [p for p in result if p.endswith("/node_modules")]
# Use basename, separator-agnostic (Windows uses \).
node_entries = [p for p in result if os.path.basename(p) == "node_modules"]
assert len(node_entries) == 1
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,10 @@ def test_writes_to_file(self, tmp_path):
plan = generate_remediation_plan(state, "python", output_path=output)

assert output.exists()
assert output.read_text() == plan
assert "Issue" in output.read_text()
# Plan contains unicode (middle dot, etc.) written as UTF-8; locale-default
# read (cp1252 on Windows) would mangle the bytes. Always read as UTF-8.
assert output.read_text(encoding="utf-8") == plan
assert "Issue" in output.read_text(encoding="utf-8")

def test_lang_name_in_commands(self):
state = _state_with_holistic_issues(
Expand Down
Loading
Loading