From 2da192db560144bf411835883ab057ea0eb4cfab Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 22 Jul 2026 19:31:19 +0100 Subject: [PATCH] fix(extract): bound the XAML code-behind .cs scan so it can't hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _xaml_csharp_class_nodes did `sorted(root.rglob("*.cs"))` where `root` comes from _xaml_project_root walking up for a .csproj/.sln marker. On a standalone extract_xaml call (no corpus boundary), a .xaml under a large/shared parent (a temp dir, or a big monorepo) could resolve `root` to a broad ancestor, and the rglob then recursively scanned the entire tree — effectively hanging (it stalled the test suite intermittently once the shared temp dir grew). Replace the rglob with an os.walk that prunes noise/hidden dirs (node_modules/.venv/.git/...) during traversal and caps directories visited, so a real project scans fully while a runaway root degrades to a fast, partial scan instead of a hang. Regression test asserts a decoy .cs in node_modules is pruned and the real ViewModel still links. --- graphify/extract.py | 26 +++++++++++++++++++++++--- tests/test_dotnet.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) 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"]}