From 9c4b7302ccdbaf1bbe5dacbecdc8c1eb39cc93bd Mon Sep 17 00:00:00 2001 From: "Ahmad Othman Ammar Adi." <78882424+OthmanAdi@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:16:06 +0200 Subject: [PATCH] fix: skip unreachable reparse points in detect_rooms_from_folders (#558) On Windows, projects containing git-submodule junctions or dev-drive reparse points cause iterdir() to list the entry successfully but Path.is_dir() to raise OSError when it calls stat() internally. Reproducer: any Windows project with a submodule checked out as a junction (e.g. skills/pr-perfect) crashes mempalace init with: OSError: [WinError 448] The path cannot be traversed because it contains an untrusted mount point Fix: wrap every is_dir() call in detect_rooms_from_folders with try/except OSError so the scanner skips inaccessible entries and continues rather than aborting. Covers both the top-level pass and the one-level-deep nested pass. Two new tests mock the OSError on specific paths and verify the function returns correct rooms from the remaining accessible entries. --- mempalace/room_detector_local.py | 35 ++++++++++++-- tests/test_room_detector_local.py | 76 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/mempalace/room_detector_local.py b/mempalace/room_detector_local.py index d86bb0b..32e75c3 100644 --- a/mempalace/room_detector_local.py +++ b/mempalace/room_detector_local.py @@ -9,12 +9,15 @@ Two ways to define rooms without calling any AI: No internet. No API key. Your files stay on your machine. """ +import logging import os import sys import yaml from pathlib import Path from collections import defaultdict +logger = logging.getLogger(__name__) + # Common room patterns — detected from folder names and filenames # Format: {folder_keyword: room_name} FOLDER_ROOM_MAP = { @@ -118,7 +121,12 @@ def detect_rooms_from_folders(project_dir: str) -> list: # Check top-level directories first (most reliable signal) for item in project_path.iterdir(): - if item.is_dir() and item.name not in SKIP_DIRS: + try: + is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point + except OSError as exc: + logger.debug("Skipping %s: %s", item, exc) + continue + if is_dir and item.name not in SKIP_DIRS: name_lower = item.name.lower().replace("-", "_") if name_lower in FOLDER_ROOM_MAP: room_name = FOLDER_ROOM_MAP[name_lower] @@ -132,9 +140,28 @@ def detect_rooms_from_folders(project_dir: str) -> list: # Walk one level deeper for nested patterns for item in project_path.iterdir(): - if item.is_dir() and item.name not in SKIP_DIRS: - for subitem in item.iterdir(): - if subitem.is_dir() and subitem.name not in SKIP_DIRS: + try: + item_is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point + except OSError as exc: + logger.debug("Skipping %s: %s", item, exc) + continue + if item_is_dir and item.name not in SKIP_DIRS: + try: + subitems = list( + item.iterdir() + ) # WinError 448 — iterdir can also fail on some reparse points + except OSError as exc: + logger.debug("Skipping contents of %s: %s", item, exc) + continue + for subitem in subitems: + try: + subitem_is_dir = ( + subitem.is_dir() + ) # WinError 448 — reparse point / untrusted mount point + except OSError as exc: + logger.debug("Skipping %s: %s", subitem, exc) + continue + if subitem_is_dir and subitem.name not in SKIP_DIRS: name_lower = subitem.name.lower().replace("-", "_") if name_lower in FOLDER_ROOM_MAP: room_name = FOLDER_ROOM_MAP[name_lower] diff --git a/tests/test_room_detector_local.py b/tests/test_room_detector_local.py index 11963e4..fd92296 100644 --- a/tests/test_room_detector_local.py +++ b/tests/test_room_detector_local.py @@ -59,6 +59,82 @@ def test_detect_rooms_from_folders_empty_dir(tmp_path): assert any(r["name"] == "general" for r in rooms) +def test_detect_rooms_from_folders_skips_oserror_at_top_level(tmp_path): + """Windows reparse points (junctions, untrusted mount points) raise OSError + when stat()'d. The scanner must skip them and continue — not crash. + + Reproduces WinError 448: "The path cannot be traversed because it contains + an untrusted mount point", seen on Windows when a project folder contains + a git-submodule junction or a dev-drive reparse point. + """ + (tmp_path / "frontend").mkdir() + bad = tmp_path / "untrusted_junction" + bad.mkdir() + + original_is_dir = bad.__class__.is_dir + + def patched_is_dir(self): + if self == bad: + raise OSError( + "[WinError 448] The path cannot be traversed because it contains an untrusted mount point" + ) + return original_is_dir(self) + + with patch.object(bad.__class__, "is_dir", patched_is_dir): + rooms = detect_rooms_from_folders(str(tmp_path)) + + room_names = {r["name"] for r in rooms} + assert "frontend" in room_names + + +def test_detect_rooms_from_folders_skips_oserror_nested(tmp_path): + """Same WinError 448 guard applies one level deeper (nested iterdir pass).""" + skills = tmp_path / "skills" + skills.mkdir() + (skills / "docs").mkdir() + bad = skills / "bad_junction" + bad.mkdir() + + original_is_dir = bad.__class__.is_dir + + def patched_is_dir(self): + if self == bad: + raise OSError( + "[WinError 448] The path cannot be traversed because it contains an untrusted mount point" + ) + return original_is_dir(self) + + with patch.object(bad.__class__, "is_dir", patched_is_dir): + rooms = detect_rooms_from_folders(str(tmp_path)) + + room_names = {r["name"] for r in rooms} + assert "documentation" in room_names + + +def test_detect_rooms_from_folders_skips_iterdir_oserror(tmp_path): + """iterdir() itself can raise OSError on some Windows reparse points even + when is_dir() succeeds. The nested pass must guard the iterdir() call too.""" + outer = tmp_path / "src" + outer.mkdir() + (tmp_path / "docs").mkdir() + + original_iterdir = outer.__class__.iterdir + + def patched_iterdir(self): + if self == outer: + raise OSError( + "[WinError 448] The path cannot be traversed because it contains an untrusted mount point" + ) + return original_iterdir(self) + + with patch.object(outer.__class__, "iterdir", patched_iterdir): + rooms = detect_rooms_from_folders(str(tmp_path)) + + room_names = {r["name"] for r in rooms} + # docs is accessible; src fails on iterdir — neither should crash + assert "documentation" in room_names + + def test_detect_rooms_from_folders_skips_git(tmp_path): (tmp_path / ".git").mkdir() (tmp_path / "node_modules").mkdir()