feat(extract): link XAML views to ViewModels and extract binding references (#1473)

Builds on the initial XAML support (#1460). Resolves a view to its ViewModel from
an explicit <Window.DataContext><vm:MainViewModel/>, a design-time
d:DataContext="{d:DesignInstance Type=...}", the View->ViewModel naming
convention, or Prism ViewModelLocator.AutoWireViewModel="True". Resolution is
always against an actually-extracted C# class node, so a name matching no class
(or an ambiguous 2+) emits no edge -- explicit DataContext is EXTRACTED,
convention/Prism are INFERRED. Also extracts binding paths ({Binding User.Name},
Path=Order.Total), commands (Command="{Binding SaveCommand}"), converters, and
CommunityToolkit [ObservableProperty]/[RelayCommand] generated members.

The #1460 event-handler hardening is preserved unchanged: events still resolve
only to methods with a .NET (object sender, ...EventArgs e) signature, and the
free-form-attribute denylist still prevents values like Content="Save" from
fabricating event edges (both regression tests still pass). ViewModel discovery is
bounded to the active extraction root.

Ported from PR #1473 by @MikeKatsoulakis (clean 3-way merge onto current v8).
Maintainer fix on top: the CommunityToolkit member reader now reads the
code-behind with errors="replace", so a non-UTF8 ViewModel .cs can't raise
UnicodeDecodeError and abort extract_xaml (matches every other reader in the
module). Added a regression test for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Katsoulakis
2026-06-26 10:23:10 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 349465b8af
commit 905e0a7a2e
15 changed files with 699 additions and 13 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Feat: link WPF/XAML views to their ViewModels and extract richer binding references (#1473, thanks @MikeKatsoulakis). Builds on the initial XAML support (#1460). Resolves a view to its ViewModel from an explicit `<Window.DataContext><vm:MainViewModel/>`, a design-time `d:DataContext="{d:DesignInstance Type=…}"`, the `View`→`ViewModel` naming convention, or Prism `ViewModelLocator.AutoWireViewModel="True"` — always against an actually-extracted C# class, so a name with no matching class (or an ambiguous one) emits no edge (explicit DataContext is EXTRACTED, conventions are INFERRED). Also extracts binding paths (`{Binding User.Name}`, `Path=Order.Total`), commands (`Command="{Binding SaveCommand}"`), converters, and CommunityToolkit `[ObservableProperty]`/`[RelayCommand]` generated members. The event-handler resolution stays gated on the .NET handler signature (no spurious event edges), and ViewModel discovery is bounded to the extraction root.
- Fix: `.vue` Single File Components now extract their `<script>` with the right grammar (#1468, thanks @papinto). `.vue` was dispatched to `extract_js`, which selects a tree-sitter grammar by suffix; `.vue` is neither `.ts` nor `.tsx`, so the whole SFC — `<template>` markup, `<script>`, and `<style>` — was parsed as JavaScript, producing a top-level ERROR node and recovering no imports, symbols, or type references. A dedicated `extract_vue` now masks everything outside `<script>` (replacing it with spaces so line numbers stay accurate) and parses just the script with the grammar named by `lang` (`ts` default, `tsx`/`js`/`jsx` honored). The open-tag scan tolerates `>` inside quoted attributes, so Vue 3.3+ generic components (`generic="T extends Record<string, unknown>"`) parse correctly.
- Fix: `graphify reflect --if-stale` now also checks the `.graphify_analysis.json` and `.graphify_labels.json` sidecars (and any custom `--analysis`/`--labels` paths) when deciding whether `LESSONS.md` is up to date (#1470, thanks @oleksii-tumanov). It previously only stat'd the memory docs and `graph.json`, so lessons could stay stale after community analysis or labels changed without the graph changing. A missing sidecar is treated as not-an-input, so no-cluster builds are unaffected.
- Fix: the `Read|Glob` PreToolUse hook (the "run graphify first" nudge installed for Claude Code and CodeBuddy) now matches the file's real trailing extension instead of substring-scanning the path (#1463, thanks @marketechniks). The old check asked `any(ext in path)`, which had two opposite failures: `.json` files (`package.json`, `tsconfig.json`) spuriously fired because `.js` is a substring of `.json`, and `.astro`/`.vue`/`.svelte` never fired because they weren't in the set — so on Astro/Vue/Svelte projects, where those are the primary source type, reads and globs never surfaced the graph. The hook now compares the segment after the last `/` then after the last `.` against the extension set (with `.astro`/`.vue`/`.svelte` added), so `package.json` stays silent, `data.geojson` stays silent, `**/*.astro` fires, and an extension sitting on a directory component (`my.ts/file`) correctly doesn't. The `graphify-out/` suppression and fail-open behavior are unchanged.
+363 -11
View File
@@ -11196,6 +11196,84 @@ _XAML_NON_EVENT_ATTRS = frozenset({
# markup, a path, or a sentence. Used to skip values like "{Binding ...}" or
# free-form content before looking them up as code-behind methods.
_XAML_IDENT_RE = re.compile(r"[A-Za-z_]\w*")
_XAML_DESIGN_INSTANCE_TYPE_RE = re.compile(
r"\bType\s*=\s*(?:\{x:Type\s+)?(?P<type>[\w.:+]+)"
)
def _xaml_markup_extension(value: str) -> tuple[str, str] | None:
value = value.strip()
if not (value.startswith("{") and value.endswith("}")):
return None
inner = value[1:-1].strip()
if not inner or inner.startswith("}"):
return None
name, _, args = inner.partition(" ")
return name, args.strip()
def _xaml_split_markup_args(args: str) -> list[str]:
parts: list[str] = []
start = 0
depth = 0
for idx, ch in enumerate(args):
if ch == "{":
depth += 1
elif ch == "}" and depth:
depth -= 1
elif ch == "," and depth == 0:
parts.append(args[start:idx].strip())
start = idx + 1
tail = args[start:].strip()
if tail:
parts.append(tail)
return parts
def _xaml_static_resource_key(value: str) -> str | None:
markup = _xaml_markup_extension(value)
if not markup:
return None
name, args = markup
if name != "StaticResource":
return None
for part in _xaml_split_markup_args(args):
if "=" not in part:
return part.strip() or None
key, resource = part.split("=", 1)
if key.strip() == "ResourceKey":
return resource.strip() or None
return None
def _xaml_binding_refs(value: str) -> tuple[str | None, str | None]:
markup = _xaml_markup_extension(value)
if not markup:
return None, None
name, args = markup
if name != "Binding":
return None, None
path_ref = None
converter_ref = None
for part in _xaml_split_markup_args(args):
if not part:
continue
if "=" not in part:
if path_ref is None:
path_ref = part.strip()
continue
key, raw_value = part.split("=", 1)
key = key.strip()
raw_value = raw_value.strip()
if key == "Path":
path_ref = raw_value
elif key == "Converter":
converter_ref = _xaml_static_resource_key(raw_value)
if path_ref and ("{" in path_ref or "}" in path_ref):
path_ref = None
return path_ref or None, converter_ref or None
def _xaml_codebehind_path(path: Path) -> Path | None:
@@ -11270,6 +11348,207 @@ def _xaml_codebehind_symbols(
return class_node, methods, class_method_edges
def _xaml_type_simple_name(type_ref: str) -> str | None:
type_ref = type_ref.strip().strip("{}")
if not type_ref:
return None
type_ref = type_ref.split(",", 1)[0].strip()
if type_ref.startswith("x:Type "):
type_ref = type_ref[len("x:Type "):].strip()
if ":" in type_ref:
type_ref = type_ref.rsplit(":", 1)[-1]
if "." in type_ref:
type_ref = type_ref.rsplit(".", 1)[-1]
if "+" in type_ref:
type_ref = type_ref.rsplit("+", 1)[-1]
return type_ref if _XAML_IDENT_RE.fullmatch(type_ref) else None
def _xaml_explicit_viewmodel_names(tree) -> tuple[bool, list[str]]:
has_data_context = False
names: list[str] = []
for elem in tree.iter():
elem_type = _xml_local_name(elem.tag)
if elem_type.endswith(".DataContext") or elem_type == "DataContext":
has_data_context = True
for child in list(elem):
vm_name = _xaml_type_simple_name(_xml_local_name(child.tag))
if vm_name and vm_name not in names:
names.append(vm_name)
for key, value in elem.attrib.items():
if _xml_local_name(key) != "DataContext" or not value:
continue
has_data_context = True
match = _XAML_DESIGN_INSTANCE_TYPE_RE.search(value)
if match:
vm_name = _xaml_type_simple_name(match.group("type"))
if vm_name and vm_name not in names:
names.append(vm_name)
return has_data_context, names
def _xaml_prism_autowire_viewmodel(tree) -> bool:
for elem in tree.iter():
for key, value in elem.attrib.items():
if (
_xml_local_name(key).endswith("ViewModelLocator.AutoWireViewModel")
and value.strip().lower() == "true"
):
return True
return False
def _xaml_inferred_viewmodel_names(view_name: str | None) -> list[str]:
if not view_name:
return []
names: list[str] = []
def add(name: str) -> None:
if name.endswith("ViewModel") and name not in names:
names.append(name)
if view_name == "MainWindow":
add("MainWindowViewModel")
add("MainViewModel")
for suffix in ("UserControl", "View", "Page", "Control"):
if view_name.endswith(suffix) and len(view_name) > len(suffix):
add(view_name[:-len(suffix)] + "ViewModel")
break
return names
def _xaml_project_root(path: Path) -> Path:
project_markers = (".csproj", ".fsproj", ".vbproj", ".sln", ".slnx")
root = path.parent
for directory in (path.parent, *path.parent.parents):
try:
if any(child.suffix in project_markers for child in directory.iterdir()):
root = directory
break
except OSError:
continue
if _XAML_ACTIVE_EXTRACT_ROOT is None:
return root
boundary = _XAML_ACTIVE_EXTRACT_ROOT.resolve()
try:
root.resolve().relative_to(boundary)
return root
except ValueError:
return boundary
def _xaml_csharp_class_nodes(path: Path) -> dict[str, list[dict]]:
from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore
root = _xaml_project_root(path)
cache_key = str(root.resolve()) if _XAML_ACTIVE_EXTRACT_ROOT is not None else None
if cache_key and cache_key in _XAML_CSHARP_CLASS_CACHE:
return _XAML_CSHARP_CLASS_CACHE[cache_key]
classes: dict[str, list[dict]] = {}
patterns = _load_graphifyignore(root)
ignore_cache: dict[Path, bool] = {}
try:
cs_files = sorted(root.rglob("*.cs"))
except OSError:
return classes
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)
if result.get("error"):
continue
for node in result.get("nodes", []):
label = str(node.get("label", ""))
if not label.endswith("ViewModel") or not _XAML_IDENT_RE.fullmatch(label):
continue
if node.get("source_file"):
classes.setdefault(label, []).append(node)
if cache_key:
_XAML_CSHARP_CLASS_CACHE[cache_key] = classes
return classes
def _xaml_pascal_name(name: str) -> str | None:
name = name.strip().lstrip("_")
if name.startswith("m_"):
name = name[2:]
return name[:1].upper() + name[1:] if _XAML_IDENT_RE.fullmatch(name) else None
_XAML_TOOLKIT_FIELD_RE = re.compile(r"\b(?P<name>_?m?_?[A-Za-z_]\w*)\s*(?:=.*)?;")
_XAML_TOOLKIT_METHOD_RE = re.compile(r"\b(?P<name>[A-Za-z_]\w*)\s*\(")
_XAML_ACTIVE_EXTRACT_ROOT: Path | None = None
_XAML_CSHARP_CLASS_CACHE: dict[str, dict[str, list[dict]]] = {}
def _xaml_communitytoolkit_members(vm_node: dict) -> tuple[dict[str, dict], list[dict]]:
source_file = vm_node.get("source_file")
vm_id = vm_node.get("id")
if not source_file or not vm_id:
return {}, []
try:
# errors="replace" so a non-UTF8 code-behind can't raise UnicodeDecodeError
# and abort the whole extract_xaml (matches every other reader here).
lines = Path(source_file).read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return {}, []
members: dict[str, dict] = {}
edges: list[dict] = []
def add_member(label: str, line_no: int, context: str) -> None:
nid = _make_id(vm_id, label)
members[label] = {
"id": nid,
"label": label,
"file_type": "code",
"source_file": source_file,
"source_location": f"L{line_no}",
}
edges.append({
"source": vm_id,
"target": nid,
"relation": "defines",
"confidence": "INFERRED",
"source_file": source_file,
"source_location": f"L{line_no}",
"weight": 1.0,
"context": context,
})
pending: tuple[str, int] | None = None
for line_no, line in enumerate(lines, 1):
remainder = line.split("]", 1)[1].strip() if "]" in line else ""
if "[" in line and "ObservableProperty" in line:
pending = ("property", line_no)
if not remainder:
continue
line = remainder
if "[" in line and "RelayCommand" in line:
pending = ("command", line_no)
if not remainder:
continue
line = remainder
if not pending or not line.strip() or line.lstrip().startswith("["):
continue
kind, attr_line = pending
pending = None
if kind == "property":
match = _XAML_TOOLKIT_FIELD_RE.search(line)
label = _xaml_pascal_name(match.group("name")) if match else None
if label:
add_member(label, attr_line, "communitytoolkit_observable_property")
else:
match = _XAML_TOOLKIT_METHOD_RE.search(line)
if match:
method = match.group("name").removesuffix("Async")
add_member(f"{method}Command", attr_line, "communitytoolkit_relay_command")
return members, edges
def extract_xaml(path: Path) -> dict:
"""Extract WPF/XAML structure, bindings, x:Class, and event handler references."""
import xml.etree.ElementTree as ET
@@ -11343,6 +11622,7 @@ def extract_xaml(path: Path) -> dict:
*,
context: str | None = None,
source_file: str = str_path,
confidence: str = "EXTRACTED",
) -> None:
key = (src_nid, tgt_nid, relation, context)
if key in seen_edges:
@@ -11350,7 +11630,7 @@ def extract_xaml(path: Path) -> dict:
seen_edges.add(key)
edge = {
"source": src_nid, "target": tgt_nid, "relation": relation,
"confidence": "EXTRACTED", "source_file": source_file,
"confidence": confidence, "source_file": source_file,
"source_location": f"L{line}", "weight": 1.0,
}
if context:
@@ -11385,7 +11665,38 @@ def extract_xaml(path: Path) -> dict:
add_node(class_nid, class_label, line_for(class_name))
add_edge(root_nid, class_nid, "references", line_for(class_name), context="x_class")
binding_re = re.compile(r"\{Binding\s+([^,}\s]+)")
has_data_context, vm_names = _xaml_explicit_viewmodel_names(tree)
prism_autowire = _xaml_prism_autowire_viewmodel(tree)
vm_confidence = "EXTRACTED"
if not has_data_context:
view_name = class_name.rsplit(".", 1)[-1] if class_name else None
view_name = view_name or (path.stem if prism_autowire else None)
vm_names = _xaml_inferred_viewmodel_names(view_name)
vm_confidence = "INFERRED"
generated_members: dict[str, dict] = {}
generated_member_edges: list[dict] = []
if vm_names:
csharp_classes = _xaml_csharp_class_nodes(path)
vm_candidates = []
for vm_name in vm_names:
vm_candidates.extend(csharp_classes.get(vm_name, []))
by_id = {node.get("id"): node for node in vm_candidates if node.get("id")}
if len(by_id) == 1:
vm_node = next(iter(by_id.values()))
add_existing_node(vm_node)
add_edge(
root_nid,
vm_node["id"],
"references",
line_for(vm_node["label"]),
context="view_model",
confidence=vm_confidence,
)
generated_members, generated_member_edges = _xaml_communitytoolkit_members(vm_node)
for member in generated_members.values():
add_existing_node(member)
for member_edge in generated_member_edges:
add_existing_edge(member_edge)
for elem in tree.iter():
elem_type = _xml_local_name(elem.tag)
@@ -11422,13 +11733,43 @@ def extract_xaml(path: Path) -> dict:
add_existing_node(class_node)
add_existing_edge(method_edge)
break
for match in binding_re.finditer(value):
binding = match.group(1).strip()
if not binding:
continue
bind_nid = _make_id("binding", binding)
add_node(bind_nid, binding, line_for(value), file_type="concept")
add_edge(owner_nid, bind_nid, "references", line_for(value), context="binding")
binding_path, binding_converter = _xaml_binding_refs(value)
if binding_path:
bind_nid = _make_id("binding", binding_path)
add_node(bind_nid, binding_path, line_for(value), file_type="concept")
binding_context = (
"binding_command"
if attr_local == "Command" or attr_local.endswith(".Command")
else "binding_path"
)
add_edge(owner_nid, bind_nid, "references", line_for(value), context=binding_context)
generated_member = generated_members.get(binding_path)
if generated_member:
add_existing_node(generated_member)
add_edge(
owner_nid,
generated_member["id"],
"references",
line_for(value),
context=binding_context,
confidence="INFERRED",
)
if binding_converter:
converter_nid = _make_id("binding_converter", binding_converter)
add_node(converter_nid, binding_converter, line_for(value), file_type="concept")
add_edge(owner_nid, converter_nid, "references", line_for(value), context="binding_converter")
if elem_type == "Binding" and attr_local == "Path":
direct_path = value.strip()
if direct_path and "{" not in direct_path and "}" not in direct_path:
bind_nid = _make_id("binding", direct_path)
add_node(bind_nid, direct_path, line_for(value), file_type="concept")
add_edge(owner_nid, bind_nid, "references", line_for(value), context="binding_path")
if elem_type == "Binding" and attr_local == "Converter":
direct_converter = _xaml_static_resource_key(value)
if direct_converter:
converter_nid = _make_id("binding_converter", direct_converter)
add_node(converter_nid, direct_converter, line_for(value), file_type="concept")
add_edge(owner_nid, converter_nid, "references", line_for(value), context="binding_converter")
return {"nodes": nodes, "edges": edges}
@@ -12440,6 +12781,16 @@ def _get_extractor(path: Path) -> Any | None:
return _DISPATCH.get(path.suffix)
def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict:
global _XAML_ACTIVE_EXTRACT_ROOT
previous_root = _XAML_ACTIVE_EXTRACT_ROOT
_XAML_ACTIVE_EXTRACT_ROOT = root.resolve()
try:
return _safe_extract(extractor, path)
finally:
_XAML_ACTIVE_EXTRACT_ROOT = previous_root
def _extract_single_file(args: tuple) -> tuple[int, dict]:
"""Worker function for parallel extraction. Runs in a subprocess.
@@ -12468,7 +12819,7 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]:
if extractor is None:
return idx, {"nodes": [], "edges": []}
result = _safe_extract(extractor, path)
result = _safe_extract_with_xaml_root(extractor, path, cache_root)
if not bypass_cache and "error" not in result:
save_cached(path, result, cache_root)
return idx, result
@@ -12592,7 +12943,7 @@ def _extract_sequential(
per_file[idx] = {"nodes": [], "edges": []}
continue
bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
result = _safe_extract(extractor, path)
result = _safe_extract_with_xaml_root(extractor, path, effective_root)
if not bypass_cache and "error" not in result:
save_cached(path, result, effective_root)
per_file[idx] = result
@@ -12632,6 +12983,7 @@ def extract(
_raise_recursion_limit()
# Workspace package manifests/globs can change during watch or repeated extraction.
_WORKSPACE_PACKAGE_CACHE.clear()
_XAML_CSHARP_CLASS_CACHE.clear()
# Infer a common root for cache keys (use first diverging segment, not sum of all matches)
try:
+12
View File
@@ -0,0 +1,12 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.Resources>
<Binding x:Key="TaxBinding" Path="Invoice.Tax" Converter="{StaticResource TaxConverter}" />
</Window.Resources>
<StackPanel x:Name="RootPanel">
<TextBlock x:Name="UserText" Text="{Binding User.Name}" />
<TextBlock x:Name="TotalText" Text="{Binding Path=Order.Total, Converter={StaticResource MoneyConverter}}" />
<Button x:Name="SaveButton" Command="{Binding SaveCommand}" />
<TextBlock x:Name="ModeText" Text="{Binding Mode=TwoWay}" />
</StackPanel>
</Window>
+5
View File
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
namespace Demo.ViewModels
{
public class DesignViewModel
{
}
}
@@ -0,0 +1,6 @@
namespace Demo.ViewModels
{
public class MainViewModel
{
}
}
@@ -0,0 +1,5 @@
namespace Demo.ViewModels;
public class PrismOrderViewModel
{
}
@@ -0,0 +1,6 @@
namespace Demo.ViewModels
{
public class SettingsViewModel
{
}
}
@@ -0,0 +1,30 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Demo.ViewModels;
public partial class ToolkitViewModel : ObservableObject
{
[ObservableProperty]
private string userName = "";
[ObservableProperty] private string email = "";
// ObservableProperty
private string ignoredName = "";
[RelayCommand]
private async Task SaveAsync()
{
await Task.CompletedTask;
}
[RelayCommand] private void Refresh()
{
}
// RelayCommand
private void Ignored()
{
}
}
+8
View File
@@ -0,0 +1,8 @@
<UserControl x:Class="Demo.DesignView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:vm="clr-namespace:Demo.ViewModels"
d:DataContext="{d:DesignInstance Type=vm:DesignViewModel}">
<Grid />
</UserControl>
@@ -0,0 +1,9 @@
<Window x:Class="Demo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Demo.ViewModels">
<Window.DataContext>
<vm:MainViewModel />
</Window.DataContext>
<Grid />
</Window>
@@ -0,0 +1,5 @@
<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True">
<Grid />
</UserControl>
+5
View File
@@ -0,0 +1,5 @@
<UserControl x:Class="Demo.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid />
</UserControl>
+19
View File
@@ -0,0 +1,19 @@
<UserControl x:Class="Demo.ToolkitView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:vm="clr-namespace:Demo.ViewModels">
<UserControl.DataContext>
<vm:ToolkitViewModel />
</UserControl.DataContext>
<Grid>
<TextBlock Text="{Binding UserName}" />
<TextBlock Text="{Binding Email}" />
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding SaveCommand}" />
<i:InvokeCommandAction Command="{Binding RefreshCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Grid>
</UserControl>
+219 -2
View File
@@ -1,8 +1,9 @@
"""Tests for .NET project file extraction (.sln, .csproj, .xaml, .razor)."""
from pathlib import Path
import shutil
import tempfile
import pytest
from graphify.extract import extract_sln, extract_slnx, extract_csproj, extract_xaml, extract_razor
from graphify.extract import extract, extract_sln, extract_slnx, extract_csproj, extract_xaml, extract_razor
FIXTURES = Path(__file__).parent / "fixtures"
@@ -15,6 +16,13 @@ def _relations(r):
return {e["relation"] for e in r["edges"]}
def _view_model_edges(r):
return [
e for e in r["edges"]
if e["relation"] == "references" and e.get("context") == "view_model"
]
# ── .sln ─────────────────────────────────────────────────────────────────────
def test_sln_extracts_projects():
@@ -129,7 +137,197 @@ def test_xaml_named_controls_and_bindings():
r = extract_xaml(FIXTURES / "sample.xaml")
labels = set(_labels(r))
assert {"RootPanel", "UserNameBox", "SaveButton", "UserName"} <= labels
assert any(e["relation"] == "references" and e.get("context") == "binding" for e in r["edges"])
assert any(e["relation"] == "references" and e.get("context") == "binding_path" for e in r["edges"])
def test_xaml_extracts_binding_paths_commands_and_converters():
r = extract_xaml(FIXTURES / "bindings.xaml")
labels_by_id = {n["id"]: n["label"] for n in r["nodes"]}
refs = {
(labels_by_id[e["target"]], e.get("context"))
for e in r["edges"]
if e["relation"] == "references"
}
assert ("User.Name", "binding_path") in refs
assert ("Order.Total", "binding_path") in refs
assert ("Invoice.Tax", "binding_path") in refs
assert ("SaveCommand", "binding_command") in refs
assert ("MoneyConverter", "binding_converter") in refs
assert ("TaxConverter", "binding_converter") in refs
assert ("TwoWay", "binding_path") not in refs
def test_xaml_element_datacontext_links_real_viewmodel_class():
r = extract_xaml(FIXTURES / "xaml_viewmodel" / "Views" / "ExplicitMainWindow.xaml")
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert len(edges) == 1
assert edges[0]["confidence"] == "EXTRACTED"
assert nodes[edges[0]["target"]]["label"] == "MainViewModel"
assert nodes[edges[0]["target"]]["source_file"].endswith("MainViewModel.cs")
def test_xaml_design_instance_datacontext_links_real_viewmodel_class():
r = extract_xaml(FIXTURES / "xaml_viewmodel" / "Views" / "DesignView.xaml")
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert len(edges) == 1
assert edges[0]["confidence"] == "EXTRACTED"
assert nodes[edges[0]["target"]]["label"] == "DesignViewModel"
def test_xaml_infers_viewmodel_by_name_only_without_datacontext():
r = extract_xaml(FIXTURES / "xaml_viewmodel" / "Views" / "SettingsView.xaml")
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert len(edges) == 1
assert edges[0]["confidence"] == "INFERRED"
assert nodes[edges[0]["target"]]["label"] == "SettingsViewModel"
def test_xaml_prism_autowire_infers_viewmodel_from_filename():
r = extract_xaml(FIXTURES / "xaml_viewmodel" / "Views" / "PrismOrderView.xaml")
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert len(edges) == 1
assert edges[0]["confidence"] == "INFERRED"
assert nodes[edges[0]["target"]]["label"] == "PrismOrderViewModel"
def test_xaml_prism_autowire_false_does_not_infer_from_filename(tmp_path):
project = tmp_path / "xaml_viewmodel"
shutil.copytree(FIXTURES / "xaml_viewmodel", project)
xaml = project / "Views" / "PrismOrderView.xaml"
xaml.write_text(
xaml.read_text(encoding="utf-8").replace(
'AutoWireViewModel="True"', 'AutoWireViewModel="False"'
),
encoding="utf-8",
)
r = extract_xaml(xaml)
assert _view_model_edges(r) == []
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"]}
refs = [
(nodes[e["target"]], e.get("context"), e["confidence"])
for e in r["edges"]
if e["relation"] == "references"
]
generated_defs = {
(nodes[e["target"]]["label"], e.get("context"))
for e in r["edges"]
if e["relation"] == "defines"
}
assert ("UserName", "communitytoolkit_observable_property") in generated_defs
assert ("Email", "communitytoolkit_observable_property") in generated_defs
assert ("SaveCommand", "communitytoolkit_relay_command") in generated_defs
assert ("RefreshCommand", "communitytoolkit_relay_command") in generated_defs
assert ("IgnoredName", "communitytoolkit_observable_property") not in generated_defs
assert ("IgnoredCommand", "communitytoolkit_relay_command") not in generated_defs
assert any(
node["label"] == "UserName"
and node["source_file"].endswith("ToolkitViewModel.cs")
and context == "binding_path"
and confidence == "INFERRED"
for node, context, confidence in refs
)
assert any(
node["label"] == "SaveCommand"
and node["source_file"].endswith("ToolkitViewModel.cs")
and context == "binding_command"
and confidence == "INFERRED"
for node, context, confidence in refs
)
assert any(
node["label"] == "Email"
and node["source_file"].endswith("ToolkitViewModel.cs")
and context == "binding_path"
and confidence == "INFERRED"
for node, context, confidence in refs
)
assert any(
node["label"] == "RefreshCommand"
and node["source_file"].endswith("ToolkitViewModel.cs")
and context == "binding_command"
and confidence == "INFERRED"
for node, context, confidence in refs
)
def test_extract_preserves_xaml_viewmodel_edge_after_id_remap(tmp_path):
project = tmp_path / "xaml_viewmodel"
shutil.copytree(FIXTURES / "xaml_viewmodel", project)
files = sorted(project.rglob("*.xaml")) + sorted(project.rglob("*.cs"))
r = extract(files, cache_root=project, parallel=False)
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert any(nodes[e["target"]]["label"] == "MainViewModel" for e in edges)
assert any(nodes[e["target"]]["label"] == "DesignViewModel" for e in edges)
assert any(
nodes[e["target"]]["label"] == "SettingsViewModel" and e["confidence"] == "INFERRED"
for e in edges
)
def test_extract_xaml_viewmodel_resolution_stays_inside_cache_root(tmp_path):
project = tmp_path / "xaml_viewmodel"
shutil.copytree(FIXTURES / "xaml_viewmodel", project)
r = extract(
[project / "Views" / "ExplicitMainWindow.xaml"],
cache_root=project / "Views",
parallel=False,
)
assert _view_model_edges(r) == []
def test_xaml_viewmodel_resolution_respects_graphifyignore(tmp_path):
project = tmp_path / "xaml_viewmodel"
shutil.copytree(FIXTURES / "xaml_viewmodel", project)
(project / ".graphifyignore").write_text("ViewModels/MainViewModel.cs\n", encoding="utf-8")
r = extract_xaml(project / "Views" / "ExplicitMainWindow.xaml")
assert _view_model_edges(r) == []
def test_xaml_ambiguous_viewmodel_names_emit_no_edge(tmp_path):
(tmp_path / "Views").mkdir()
(tmp_path / "ViewModels").mkdir()
(tmp_path / "App.csproj").write_text("<Project Sdk=\"Microsoft.NET.Sdk\" />", encoding="utf-8")
xaml = (
'<Window x:Class="Demo.MainWindow"\n'
' xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"\n'
' xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">\n'
"</Window>\n"
)
(tmp_path / "Views" / "MainWindow.xaml").write_text(xaml, encoding="utf-8")
(tmp_path / "ViewModels" / "MainWindowViewModel.cs").write_text(
"namespace Demo { public class MainWindowViewModel { } }\n",
encoding="utf-8",
)
(tmp_path / "ViewModels" / "MainViewModel.cs").write_text(
"namespace Demo { public class MainViewModel { } }\n",
encoding="utf-8",
)
r = extract_xaml(tmp_path / "Views" / "MainWindow.xaml")
assert _view_model_edges(r) == []
def test_xaml_events_resolve_to_codebehind_methods():
@@ -210,6 +408,25 @@ def test_xaml_non_event_attribute_value_does_not_fabricate_event():
assert len(targets) == 1
def test_xaml_viewmodel_with_non_utf8_codebehind_does_not_crash(tmp_path):
"""A ViewModel .cs with invalid UTF-8 bytes must not abort extract_xaml: the
CommunityToolkit member reader uses errors='replace' like every other reader."""
project = tmp_path / "xaml_viewmodel"
shutil.copytree(FIXTURES / "xaml_viewmodel", project)
vm = project / "ViewModels" / "SettingsViewModel.cs"
# prepend a stray non-UTF8 byte (0xFF) before valid source
vm.write_bytes(b"\xff// stray byte\n" + vm.read_bytes())
r = extract_xaml(project / "Views" / "SettingsView.xaml")
assert "error" not in r
# the VM class is still found (extract_csharp reads bytes), so the inferred edge survives
nodes = {n["id"]: n for n in r["nodes"]}
edges = _view_model_edges(r)
assert len(edges) == 1
assert nodes[edges[0]["target"]]["label"] == "SettingsViewModel"
# ── .razor ───────────────────────────────────────────────────────────────────
def test_razor_using_and_inject():