diff --git a/graphify/extract.py b/graphify/extract.py
index 00f8dda6e..e5a48dc04 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -3581,13 +3581,33 @@ def _xaml_csharp_class_nodes(path: Path) -> dict[str, list[dict]]:
classes: dict[str, list[dict]] = {}
patterns = _load_graphifyignore(root)
ignore_cache: dict[Path, bool] = {}
+ # Prune noise/hidden dirs DURING traversal (not after) so the scan never
+ # descends into node_modules/.venv/.git/build/..., and CAP the number of
+ # directories visited. rglob("*.cs") used to walk the entire tree first,
+ # which on a mis-resolved or huge root (e.g. a .xaml under a shared temp dir
+ # or a giant monorepo, where _xaml_project_root climbs to a broad ancestor)
+ # scanned millions of paths and effectively hung. A real .NET project sits
+ # well under the cap; a runaway root is bounded to a fast, partial scan
+ # instead of hanging.
+ import os as _os
+ _DIR_CAP = 20000
+ cs_files: list[Path] = []
+ visited = 0
try:
- cs_files = sorted(root.rglob("*.cs"))
+ for dirpath, dirnames, filenames in _os.walk(root):
+ dirnames[:] = [
+ d for d in dirnames if not d.startswith(".") and not _is_noise_dir(d)
+ ]
+ for fn in filenames:
+ if fn.endswith(".cs"):
+ cs_files.append(Path(dirpath) / fn)
+ visited += 1
+ if visited >= _DIR_CAP:
+ break
except OSError:
return classes
+ cs_files.sort()
for cs_path in cs_files:
- if any(_is_noise_dir(part) for part in cs_path.parts):
- continue
if patterns and _is_ignored(cs_path, root, patterns, _cache=ignore_cache):
continue
result = extract_csharp(cs_path)
diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py
index fac4fd0e6..ec48f8b75 100644
--- a/tests/test_dotnet.py
+++ b/tests/test_dotnet.py
@@ -273,6 +273,40 @@ def test_xaml_prism_autowire_false_does_not_infer_from_filename(tmp_path):
assert _view_model_edges(r) == []
+def test_xaml_cs_scan_prunes_noise_dirs_and_stays_bounded(tmp_path):
+ """The code-behind/.cs scan prunes noise dirs (node_modules/.venv/.git/...)
+ during traversal and is bounded, so it links the real ViewModel while a decoy
+ .cs buried in node_modules is never scanned — and it can't rglob a huge tree
+ and hang (the standalone-root escape that stalled the suite)."""
+ proj = tmp_path / "App"
+ (proj / "Views").mkdir(parents=True)
+ (proj / "ViewModels").mkdir()
+ (proj / "App.csproj").write_text('', encoding="utf-8")
+ (proj / "Views" / "MainWindow.xaml").write_text(
+ '\n'
+ ' \n'
+ "\n", encoding="utf-8")
+ (proj / "ViewModels" / "MainWindowViewModel.cs").write_text(
+ "namespace App.ViewModels { public class MainWindowViewModel {} }\n", encoding="utf-8")
+ # A decoy with the SAME class name inside a noise dir: if pruning failed it
+ # would be scanned and make the link ambiguous/wrong.
+ nm = proj / "node_modules" / "pkg"
+ nm.mkdir(parents=True)
+ (nm / "Decoy.cs").write_text(
+ "namespace App.ViewModels { public class MainWindowViewModel {} }\n", encoding="utf-8")
+ r = extract_xaml(proj / "Views" / "MainWindow.xaml")
+ assert "error" not in r
+ nodes = {n["id"]: n for n in r["nodes"]}
+ edges = _view_model_edges(r)
+ assert len(edges) == 1
+ tgt = nodes[edges[0]["target"]]
+ assert tgt["label"] == "MainWindowViewModel"
+ assert "node_modules" not in (tgt.get("source_file") or ""), "decoy in node_modules was scanned"
+
+
def test_xaml_links_communitytoolkit_generated_members_and_event_to_command():
r = extract_xaml(FIXTURES / "xaml_viewmodel" / "Views" / "ToolkitView.xaml")
nodes = {n["id"]: n for n in r["nodes"]}