feat(extract): WPF/XAML structural extraction with code-behind bridge (#1460)
Makes .xaml a first-class code input. extract_xaml() uses stdlib XML (no new
parser dependency) behind the same DOCTYPE/ENTITY and size guards as the .csproj
extractor, and captures: the root element, named controls (x:Name/Name) and their
control types, {Binding ...} references, x:Class, and -- the useful part -- a
bridge from the view markup to its .xaml.cs code-behind by resolving event-handler
attributes to the matching methods on the partial class.
Ported from PR #1460 by @MikeKatsoulakis onto current v8.
Maintainer hardening on top of the original PR: event resolution is now gated so
it can't fabricate edges. The original matched any attribute value against
code-behind method names, so Content="Save" next to a business method Save(), or
Tag="<a-handler-name>", produced spurious "event" edges. Resolution now requires
(a) the attribute is not a known free-form/identity property (Content, Text, Tag,
Title, ToolTip, Header, ...), (b) the value is a bare identifier, and (c) the
matched method actually has the .NET event-handler signature
(object sender, <T>EventArgs e) -- read from the code-behind source since the C#
extractor does not record parameter lists on method nodes. Added regression tests
for both false-positive cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
68dba89a99
commit
7dc5d968a3
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Feat: extract WPF/XAML structure from `.xaml` files (#1460, thanks @MikeKatsoulakis). No new parser dependency (stdlib XML, with the same DOCTYPE/ENTITY and size guards as the `.csproj` extractor). Captures the root element, named controls (`x:Name`/`Name`) and their control types, `{Binding ...}` references, and `x:Class`, and bridges the view to its `.xaml.cs` code-behind by resolving event-handler attributes to the matching methods on the partial class. Event resolution is gated on the .NET handler signature `(object sender, …EventArgs e)` and skips free-form attributes (`Content`, `Text`, `Tag`, …), so a property value that merely matches a method name (e.g. `Content="Save"` next to a business method `Save()`) can't fabricate a spurious event edge.
|
||||
- Fix: `to_canvas` (Obsidian Canvas export) now lays out each community's node cards in the same `ceil(sqrt(n))`-column grid the group box is sized for. The box width assumed a roughly-square `sqrt(n)`-column layout, but the placement loop hardcoded 3 columns, so any community larger than ~9 members rendered as a cramped 3-wide strip in an over-wide, mostly-empty box. The column count is now computed once per community and reused for the box width, box height, and card placement, so the cards fill the box. Cosmetic, no data change (#1452, thanks @TPAteeq).
|
||||
- Fix: `to_obsidian` / `to_canvas` / `to_wiki` no longer silently overwrite notes whose labels differ only by case (e.g. a class `References` and a prose heading `references`). The filename dedup was keyed on the exact-case name, so two such labels counted as non-colliding and the second write clobbered the first on case-insensitive filesystems (macOS/APFS, Windows/NTFS) — no suffix, no warning. Dedup now folds case (keyed on the lowercased name) while still emitting the original-case filename, so any pair that would collide on disk gets a numeric suffix. The obsidian/canvas dedup is shared in one helper so they can't drift, `wiki`'s slug dedup gets the matching fix, the `_COMMUNITY_*.md` overview notes (which had no dedup) are covered, and a generated `base_1` is itself re-checked so it can't overwrite a node literally labelled `base_1` (#1453, thanks @TPAteeq).
|
||||
- Feat: the `kimi`, `gemini`, and `deepseek` semantic-extraction backends now honor `KIMI_BASE_URL`, `GEMINI_BASE_URL`, and `DEEPSEEK_BASE_URL` to point at any OpenAI-compatible endpoint (a proxy, gateway, or self-hosted relay), matching the existing `OLLAMA_BASE_URL` / `OPENAI_BASE_URL` overrides. Each falls back to its hardcoded official default when the variable is unset, so behavior is unchanged for everyone who doesn't set it (#1458, thanks @jc2shile).
|
||||
|
||||
@@ -242,7 +242,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
|
||||
|
||||
| Type | Extensions |
|
||||
|------|-----------|
|
||||
| Code (36 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` reuse the C++ grammar) |
|
||||
| Code (36 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` reuse the C++ grammar) |
|
||||
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
|
||||
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
|
||||
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class FileType(str, Enum):
|
||||
|
||||
_MANIFEST_PATH = str(out_path("manifest.json"))
|
||||
|
||||
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'}
|
||||
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
|
||||
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
|
||||
PAPER_EXTENSIONS = {'.pdf'}
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
@@ -11036,6 +11036,267 @@ def extract_csproj(path: Path) -> dict:
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _xml_local_name(name: str) -> str:
|
||||
return name.rsplit("}", 1)[-1] if name.startswith("{") else name
|
||||
|
||||
|
||||
# A .NET event handler has the signature `(object sender, <T>EventArgs e)`. Used
|
||||
# to tell a real event handler in the code-behind apart from an ordinary method
|
||||
# whose name a XAML attribute value happens to match. Tolerates `object?`, a
|
||||
# namespace-qualified args type, and a generic `EventArgs<T>`.
|
||||
_EVENT_HANDLER_SIGNATURE_RE = re.compile(
|
||||
r"\(\s*object\??\s+\w+\s*,\s*[\w.]*EventArgs(?:<[^>]*>)?\s+\w+\s*\)"
|
||||
)
|
||||
|
||||
# XAML attribute names that carry free-form strings or identifiers and never name
|
||||
# an event handler. They are skipped when matching attribute values to code-behind
|
||||
# methods so e.g. Content="Save" or Tag="Refresh" can't fabricate an event edge.
|
||||
_XAML_NON_EVENT_ATTRS = frozenset({
|
||||
"Name", "Content", "Text", "Title", "Tag", "ToolTip", "Header",
|
||||
"Class", "Key", "Uid", "DataContext", "Style", "Source",
|
||||
})
|
||||
|
||||
# A handler attribute value is a bare method name (e.g. Click="Save_Click"), not
|
||||
# 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*")
|
||||
|
||||
|
||||
def _xaml_codebehind_path(path: Path) -> Path | None:
|
||||
expected = path.with_suffix(path.suffix + ".cs")
|
||||
if expected.exists():
|
||||
return expected
|
||||
try:
|
||||
for sibling in path.parent.iterdir():
|
||||
if sibling.name.casefold() == expected.name.casefold():
|
||||
return sibling
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _xaml_codebehind_symbols(
|
||||
path: Path,
|
||||
class_name: str | None,
|
||||
) -> tuple[dict | None, dict[str, dict], list[dict]]:
|
||||
codebehind = _xaml_codebehind_path(path)
|
||||
if not codebehind:
|
||||
return None, {}, []
|
||||
result = extract_csharp(codebehind)
|
||||
if result.get("error"):
|
||||
return None, {}, []
|
||||
|
||||
class_simple = class_name.rsplit(".", 1)[-1] if class_name else None
|
||||
class_node = None
|
||||
if class_simple:
|
||||
for node in result.get("nodes", []):
|
||||
if node.get("label") == class_simple:
|
||||
class_node = node
|
||||
break
|
||||
|
||||
class_method_edges: list[dict] = []
|
||||
if class_node:
|
||||
class_id = class_node.get("id")
|
||||
for edge in result.get("edges", []):
|
||||
if edge.get("source") == class_id and edge.get("relation") == "method":
|
||||
class_method_edges.append(edge)
|
||||
method_ids = {edge.get("target") for edge in class_method_edges} if class_node else None
|
||||
|
||||
# Only methods with a .NET event-handler signature -- (object sender,
|
||||
# <T>EventArgs e) -- are eligible to be wired to a XAML attribute as an
|
||||
# event. Without this gate, any attribute whose value happens to match a
|
||||
# method name (e.g. Content="Save" next to a business method Save()) would
|
||||
# produce a spurious "event" edge. The C# extractor does not record the
|
||||
# parameter list on method nodes, so we read it from the code-behind source
|
||||
# at the method's recorded line.
|
||||
try:
|
||||
cb_lines = codebehind.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
cb_lines = []
|
||||
|
||||
def _has_event_handler_signature(node: dict) -> bool:
|
||||
loc = str(node.get("source_location") or "")
|
||||
m = re.match(r"L(\d+)", loc)
|
||||
if not m or not cb_lines:
|
||||
return False
|
||||
start = int(m.group(1)) - 1
|
||||
# Join a few lines so a signature split across lines still matches.
|
||||
snippet = " ".join(cb_lines[start:start + 3])
|
||||
return _EVENT_HANDLER_SIGNATURE_RE.search(snippet) is not None
|
||||
|
||||
methods: dict[str, dict] = {}
|
||||
for node in result.get("nodes", []):
|
||||
if method_ids is not None and node.get("id") not in method_ids:
|
||||
continue
|
||||
label = str(node.get("label", ""))
|
||||
if label.startswith(".") and label.endswith("()") and _has_event_handler_signature(node):
|
||||
methods[label.strip("()").lstrip(".")] = node
|
||||
return class_node, methods, class_method_edges
|
||||
|
||||
|
||||
def extract_xaml(path: Path) -> dict:
|
||||
"""Extract WPF/XAML structure, bindings, x:Class, and event handler references."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
try:
|
||||
src = path.read_bytes()
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
|
||||
|
||||
if len(src) > _PROJECT_XML_MAX_BYTES:
|
||||
return {"nodes": [], "edges": [], "error": "xaml file too large"}
|
||||
if not _project_xml_is_safe(src):
|
||||
return {"nodes": [], "edges": [],
|
||||
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(src)
|
||||
except ET.ParseError as e:
|
||||
return {"nodes": [], "edges": [], "error": f"XML parse error: {e}"}
|
||||
|
||||
text = src.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
str_path = str(path)
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str(path))
|
||||
root_type = _xml_local_name(tree.tag)
|
||||
root_nid = _make_id(stem, root_type)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_edges: set[tuple[str, str, str, str | None]] = set()
|
||||
|
||||
def line_for(value: str | None) -> int:
|
||||
if value:
|
||||
for idx, line in enumerate(lines, 1):
|
||||
if value in line:
|
||||
return idx
|
||||
return 1
|
||||
|
||||
def add_node(
|
||||
nid: str,
|
||||
label: str,
|
||||
line: int | None,
|
||||
*,
|
||||
file_type: str = "code",
|
||||
source_file: str = str_path,
|
||||
) -> None:
|
||||
if nid in seen_ids:
|
||||
return
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid, "label": label, "file_type": file_type,
|
||||
"source_file": source_file,
|
||||
"source_location": f"L{line}" if line else None,
|
||||
})
|
||||
|
||||
def add_existing_node(node: dict | None) -> None:
|
||||
if not node:
|
||||
return
|
||||
nid = node.get("id")
|
||||
if not nid or nid in seen_ids:
|
||||
return
|
||||
seen_ids.add(nid)
|
||||
nodes.append(dict(node))
|
||||
|
||||
def add_edge(
|
||||
src_nid: str,
|
||||
tgt_nid: str,
|
||||
relation: str,
|
||||
line: int,
|
||||
*,
|
||||
context: str | None = None,
|
||||
source_file: str = str_path,
|
||||
) -> None:
|
||||
key = (src_nid, tgt_nid, relation, context)
|
||||
if key in seen_edges:
|
||||
return
|
||||
seen_edges.add(key)
|
||||
edge = {
|
||||
"source": src_nid, "target": tgt_nid, "relation": relation,
|
||||
"confidence": "EXTRACTED", "source_file": source_file,
|
||||
"source_location": f"L{line}", "weight": 1.0,
|
||||
}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
def add_existing_edge(edge: dict) -> None:
|
||||
key = (edge.get("source"), edge.get("target"), edge.get("relation"), edge.get("context"))
|
||||
if key in seen_edges:
|
||||
return
|
||||
seen_edges.add(key)
|
||||
edges.append(dict(edge))
|
||||
|
||||
add_node(file_nid, path.name, 1)
|
||||
add_node(root_nid, root_type, 1)
|
||||
add_edge(file_nid, root_nid, "contains", 1)
|
||||
|
||||
class_name = None
|
||||
for key, value in tree.attrib.items():
|
||||
if _xml_local_name(key) == "Class" and value:
|
||||
class_name = value.strip()
|
||||
break
|
||||
|
||||
class_node, codebehind_methods, class_method_edges = _xaml_codebehind_symbols(path, class_name)
|
||||
if class_name:
|
||||
if class_node:
|
||||
class_nid = class_node["id"]
|
||||
add_existing_node(class_node)
|
||||
else:
|
||||
class_label = class_name.rsplit(".", 1)[-1]
|
||||
class_nid = _make_id(stem, class_label)
|
||||
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]+)")
|
||||
|
||||
for elem in tree.iter():
|
||||
elem_type = _xml_local_name(elem.tag)
|
||||
elem_name = None
|
||||
for key, value in elem.attrib.items():
|
||||
if _xml_local_name(key) == "Name" and value:
|
||||
elem_name = value.strip()
|
||||
break
|
||||
owner_nid = root_nid
|
||||
if elem_name:
|
||||
owner_nid = _make_id(stem, elem_name)
|
||||
add_node(owner_nid, elem_name, line_for(elem_name))
|
||||
add_edge(root_nid, owner_nid, "contains", line_for(elem_name))
|
||||
type_nid = _make_id("xaml", elem_type)
|
||||
add_node(type_nid, elem_type, line_for(elem_name), file_type="concept")
|
||||
add_edge(owner_nid, type_nid, "references", line_for(elem_name), context="type")
|
||||
|
||||
for key, value in elem.attrib.items():
|
||||
value = value or ""
|
||||
# Event wiring: an attribute references a handler only when its local
|
||||
# name isn't a known free-form/identity property, its value is a bare
|
||||
# identifier (a method name, not markup or a sentence), and the matched
|
||||
# code-behind method actually has an event-handler signature (the gate
|
||||
# in _xaml_codebehind_symbols). This stops Content="Save" / Tag="..."
|
||||
# from fabricating event edges against same-named ordinary methods.
|
||||
attr_local = _xml_local_name(key)
|
||||
if attr_local not in _XAML_NON_EVENT_ATTRS and _XAML_IDENT_RE.fullmatch(value):
|
||||
method = codebehind_methods.get(value)
|
||||
if method:
|
||||
add_existing_node(method)
|
||||
add_edge(owner_nid, method["id"], "references", line_for(value), context="event")
|
||||
for method_edge in class_method_edges:
|
||||
if method_edge.get("target") == method["id"]:
|
||||
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")
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
|
||||
|
||||
# Config/manifest JSON filenames the structural extractor understands. Anything
|
||||
@@ -12018,6 +12279,7 @@ _DISPATCH: dict[str, Any] = {
|
||||
".csproj": extract_csproj,
|
||||
".fsproj": extract_csproj,
|
||||
".vbproj": extract_csproj,
|
||||
".xaml": extract_xaml,
|
||||
".razor": extract_razor,
|
||||
".cshtml": extract_razor,
|
||||
".cls": extract_apex,
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<Window x:Class="GraphifyDemo.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Dashboard"
|
||||
Loaded="Window_Loaded">
|
||||
<StackPanel x:Name="RootPanel">
|
||||
<TextBox Name="UserNameBox" Text="{Binding UserName}" TextChanged="UserNameChanged" />
|
||||
<Button x:Name="SaveButton" Content="Save" Click="Save_Click" />
|
||||
</StackPanel>
|
||||
</Window>
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace GraphifyDemo
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private void Window_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
}
|
||||
|
||||
private void UserNameChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-4
@@ -1,8 +1,8 @@
|
||||
"""Tests for .NET project file extraction (.sln, .csproj, .razor)."""
|
||||
"""Tests for .NET project file extraction (.sln, .csproj, .xaml, .razor)."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import pytest
|
||||
from graphify.extract import extract_sln, extract_slnx, extract_csproj, extract_razor
|
||||
from graphify.extract import extract_sln, extract_slnx, extract_csproj, extract_xaml, extract_razor
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
@@ -107,6 +107,109 @@ def test_csproj_invalid_xml():
|
||||
assert "error" in r
|
||||
|
||||
|
||||
# ── .xaml ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_xaml_class_resolves_to_codebehind_partial_class():
|
||||
r = extract_xaml(FIXTURES / "sample.xaml")
|
||||
assert "error" not in r
|
||||
class_nodes = [
|
||||
n for n in r["nodes"]
|
||||
if n["label"] == "MainWindow" and str(n.get("source_file", "")).endswith("sample.xaml.cs")
|
||||
]
|
||||
assert class_nodes
|
||||
assert any(
|
||||
e["relation"] == "references"
|
||||
and e.get("context") == "x_class"
|
||||
and e["target"] == class_nodes[0]["id"]
|
||||
for e in r["edges"]
|
||||
)
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
def test_xaml_events_resolve_to_codebehind_methods():
|
||||
r = extract_xaml(FIXTURES / "sample.xaml")
|
||||
method_nodes = {
|
||||
n["label"].strip("()").lstrip("."): n["id"]
|
||||
for n in r["nodes"]
|
||||
if str(n.get("source_file", "")).endswith("sample.xaml.cs")
|
||||
}
|
||||
assert {"Window_Loaded", "UserNameChanged", "Save_Click"} <= set(method_nodes)
|
||||
event_targets = {
|
||||
e["target"] for e in r["edges"]
|
||||
if e["relation"] == "references" and e.get("context") == "event"
|
||||
}
|
||||
assert method_nodes["Window_Loaded"] in event_targets
|
||||
assert method_nodes["UserNameChanged"] in event_targets
|
||||
assert method_nodes["Save_Click"] in event_targets
|
||||
|
||||
|
||||
def _event_targets(r):
|
||||
return {e["target"] for e in r["edges"]
|
||||
if e["relation"] == "references" and e.get("context") == "event"}
|
||||
|
||||
|
||||
def test_xaml_event_match_requires_handler_signature():
|
||||
"""A property value that matches an ordinary method's name must not become an
|
||||
event edge -- only methods with a (object sender, ...EventArgs e) signature do."""
|
||||
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'
|
||||
' <Button Content="Refresh" Click="Refresh"/>\n'
|
||||
"</Window>\n"
|
||||
)
|
||||
cs = (
|
||||
"using System.Windows;\n"
|
||||
"namespace Demo { public partial class MainWindow : Window {\n"
|
||||
" public void Refresh() {}\n" # business method, not a handler signature
|
||||
"}}\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "view.xaml"
|
||||
p.write_text(xaml)
|
||||
(Path(d) / "view.xaml.cs").write_text(cs)
|
||||
r = extract_xaml(p)
|
||||
assert "error" not in r
|
||||
assert _event_targets(r) == set()
|
||||
|
||||
|
||||
def test_xaml_non_event_attribute_value_does_not_fabricate_event():
|
||||
"""Content=/Tag= holding a string that equals a real handler's name must not
|
||||
create an event edge; only the genuine event attribute (Click) should."""
|
||||
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'
|
||||
' <Button x:Name="B1" Content="Save_Click" Tag="OnLoaded" Click="Save_Click"/>\n'
|
||||
"</Window>\n"
|
||||
)
|
||||
cs = (
|
||||
"using System.Windows;\n"
|
||||
"namespace Demo { public partial class MainWindow : Window {\n"
|
||||
" private void Save_Click(object sender, RoutedEventArgs e) {}\n"
|
||||
" private void OnLoaded(object sender, RoutedEventArgs e) {}\n"
|
||||
"}}\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "view.xaml"
|
||||
p.write_text(xaml)
|
||||
(Path(d) / "view.xaml.cs").write_text(cs)
|
||||
r = extract_xaml(p)
|
||||
handlers = {n["label"].strip("()").lstrip("."): n["id"]
|
||||
for n in r["nodes"] if str(n.get("source_file", "")).endswith("view.xaml.cs")}
|
||||
targets = _event_targets(r)
|
||||
# Click -> Save_Click is the only real event; OnLoaded (referenced only via Tag) is not.
|
||||
assert handlers["Save_Click"] in targets
|
||||
assert handlers.get("OnLoaded") not in targets
|
||||
assert len(targets) == 1
|
||||
|
||||
|
||||
# ── .razor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_razor_using_and_inject():
|
||||
@@ -150,11 +253,11 @@ def test_razor_missing_file():
|
||||
|
||||
def test_dispatch_table():
|
||||
from graphify.extract import _get_extractor
|
||||
for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml"):
|
||||
for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".xaml", ".razor", ".cshtml"):
|
||||
assert _get_extractor(Path(f"foo{ext}")) is not None, f"{ext} not in dispatch"
|
||||
|
||||
|
||||
def test_code_extensions():
|
||||
from graphify.detect import CODE_EXTENSIONS
|
||||
for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml"):
|
||||
for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".xaml", ".razor", ".cshtml"):
|
||||
assert ext in CODE_EXTENSIONS, f"{ext} missing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files."""
|
||||
"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
@@ -6,7 +6,7 @@ from graphify.extract import (
|
||||
extract_java, extract_c, extract_cpp, extract_ruby,
|
||||
extract_csharp, extract_kotlin, extract_scala, extract_php,
|
||||
extract_swift, extract_go, extract_julia, extract_js, extract_fortran,
|
||||
extract_groovy, extract_sln, extract_csproj, extract_razor,
|
||||
extract_groovy, extract_sln, extract_csproj, extract_xaml, extract_razor,
|
||||
extract_dm, extract_dmi, extract_dmm, extract_dmf,
|
||||
extract_powershell, extract_apex, extract_verilog,
|
||||
extract_powershell_manifest,
|
||||
@@ -1755,7 +1755,7 @@ def test_dmf_no_dangling_edges():
|
||||
assert e["target"] in node_ids
|
||||
|
||||
|
||||
# -- .NET project files (.sln, .csproj, .razor) -------------------------------
|
||||
# -- .NET project files (.sln, .csproj, .xaml, .razor) ------------------------
|
||||
|
||||
def test_sln_no_error():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
@@ -1798,6 +1798,12 @@ def test_csproj_finds_sdk():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert any("Microsoft.NET.Sdk.Web" in l for l in _labels(r))
|
||||
|
||||
def test_xaml_finds_class_and_event_references():
|
||||
r = extract_xaml(FIXTURES / "sample.xaml")
|
||||
assert "error" not in r
|
||||
assert "MainWindow" in _labels(r)
|
||||
assert any(e["relation"] == "references" and e.get("context") == "event" for e in r["edges"])
|
||||
|
||||
def test_razor_no_error():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "error" not in r
|
||||
|
||||
Reference in New Issue
Block a user