diff --git a/loopx/contract.py b/loopx/contract.py index ae1db7a0f8..1549b89712 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -750,16 +750,22 @@ def iter_scan_files(scan_root: Path) -> list[Path]: files: list[Path] = [] tracked_files = _tracked_scan_files(scan_root) root_parts = set(scan_root.parts) - if any(part in DEFAULT_SKIP_DIRS or part.endswith(".egg-info") for part in root_parts): + # Dependency pruning never overrides tracked repository ownership. + if ( + any(part in DEFAULT_SKIP_DIRS or part.endswith(".egg-info") for part in root_parts) + or os.path.isfile(scan_root / "pyvenv.cfg") + ): return sorted(tracked_files) for dir_path, dir_names, file_names in os.walk(scan_root): + current_dir = Path(dir_path) dir_names[:] = [ name for name in dir_names - if name not in DEFAULT_SKIP_DIRS and not name.endswith(".egg-info") + if name not in DEFAULT_SKIP_DIRS + and not name.endswith(".egg-info") + and not os.path.isfile(current_dir / name / "pyvenv.cfg") ] - current_dir = Path(dir_path) for file_name in file_names: path = (current_dir / file_name).resolve() if path.name.endswith(".local.json"): diff --git a/tests/test_contract_scan_unreadable_files.py b/tests/test_contract_scan_unreadable_files.py index 9968fb9b7b..e508ea336e 100644 --- a/tests/test_contract_scan_unreadable_files.py +++ b/tests/test_contract_scan_unreadable_files.py @@ -80,3 +80,32 @@ def test_scan_public_boundary_reports_unreadable_file(tmp_path: Path) -> None: assert payload["ok"] is True assert payload["unreadable_files"] == ["locked.md: Permission denied"] + + +def test_virtualenv_pruning_preserves_tracked_and_explicit_scan_targets(tmp_path: Path) -> None: + repo = tmp_path / "repo" + env = repo / "custom-python" + env.mkdir(parents=True) + (env / "pyvenv.cfg").write_text("include-system-site-packages = false\n") + payload = 'AUTH = "' + "tok" + 'en=syntheticsyntheticsynthetic"\n' + dependency = env / "dependency.py" + owned = env / "owned.py" + unmarked = repo / "unmarked" / "dependency.py" + unmarked.parent.mkdir() + for path in (dependency, owned, unmarked): + path.write_text(payload) + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "add", "custom-python/owned.py"], check=True) + + # Marked dependencies are omitted; owned content cannot hide behind a marker. + assert iter_scan_files(repo) == sorted([owned, unmarked]) + assert iter_scan_files(env) == [owned] + result = scan_public_boundary([repo]) + assert result["scanned_files"] == 2 + assert result["ok"] is False + assert any("custom-python/owned.py" in hit for hit in result["hits"]) + # An explicit file remains an explicit request even inside a pruned tree. + assert iter_scan_files(dependency) == [dependency] + assert scan_public_boundary([dependency])["hits"] + (env / "pyvenv.cfg").unlink() + assert dependency in iter_scan_files(repo)