diff --git a/README.md b/README.md
index b7ca372..465a5bb 100644
--- a/README.md
+++ b/README.md
@@ -237,7 +237,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| Type | Extensions |
|------|-----------|
-| Code (28 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .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 .csproj .fsproj .vbproj .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`) |
+| Code (28 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .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]`) |
| 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 |
diff --git a/graphify/detect.py b/graphify/detect.py
index 2eff3f8..afb8d6d 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -25,7 +25,7 @@ class FileType(str, Enum):
_MANIFEST_PATH = "graphify-out/manifest.json"
-CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.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', '.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', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.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'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
diff --git a/graphify/extract.py b/graphify/extract.py
index a40304b..5cae711 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -9814,7 +9814,7 @@ def extract_bash(path: Path) -> dict:
return {"nodes": nodes, "edges": edges}
-# ── .NET project files (.sln, .csproj, .razor) ──────────────────────────────
+# ── .NET project files (.sln, .slnx, .csproj, .razor) ───────────────────────
def extract_sln(path: Path) -> dict:
"""Extract projects and inter-project dependencies from a .sln file."""
@@ -9890,6 +9890,92 @@ def extract_sln(path: Path) -> dict:
return {"nodes": nodes, "edges": edges}
+def extract_slnx(path: Path) -> dict:
+ """Extract projects and inter-project dependencies from a .slnx file.
+
+ .slnx is the XML-based replacement for the legacy .sln format. Projects
+ are listed as ```` elements (optionally nested inside
+ ```` elements) and build-order dependencies as ```` children. Unlike .sln there are no GUIDs -- projects are
+ identified by their path.
+ """
+ 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": "project 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}"}
+
+ file_nid = _make_id(str(path))
+ str_path = str(path)
+ nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
+ "source_file": str_path, "source_location": None}]
+ edges: list[dict] = []
+ seen_ids: set[str] = set()
+ seen_ids.add(file_nid)
+
+ ns = ""
+ if tree.tag.startswith("{"):
+ ns = tree.tag.split("}")[0] + "}"
+
+ def _resolve(proj_path: str) -> str:
+ proj_path = proj_path.replace("\\", "/")
+ try:
+ return str((path.parent / proj_path).resolve())
+ except Exception:
+ return proj_path
+
+ # First pass: collect projects (anywhere in the tree, incl. ).
+ project_nids: set[str] = set()
+ for proj in tree.iter(f"{ns}Project"):
+ proj_path = proj.get("Path")
+ if not proj_path:
+ continue
+ abs_proj = _resolve(proj_path)
+ proj_nid = _make_id(abs_proj)
+ if proj_nid and proj_nid not in seen_ids:
+ seen_ids.add(proj_nid)
+ label = Path(proj_path).stem
+ nodes.append({"id": proj_nid, "label": label,
+ "file_type": "code", "source_file": abs_proj,
+ "source_location": None})
+ edges.append({"source": file_nid, "target": proj_nid,
+ "relation": "contains", "confidence": "EXTRACTED",
+ "source_file": str_path, "weight": 1.0})
+ if proj_nid:
+ project_nids.add(proj_nid)
+
+ # Second pass: build-order dependencies between known projects.
+ for proj in tree.iter(f"{ns}Project"):
+ proj_path = proj.get("Path")
+ if not proj_path:
+ continue
+ from_nid = _make_id(_resolve(proj_path))
+ for dep in proj.iter(f"{ns}BuildDependency"):
+ dep_path = dep.get("Project")
+ if not dep_path:
+ continue
+ to_nid = _make_id(_resolve(dep_path))
+ if (from_nid and to_nid and from_nid != to_nid
+ and to_nid in project_nids):
+ edges.append({"source": from_nid, "target": to_nid,
+ "relation": "imports", "confidence": "EXTRACTED",
+ "source_file": str_path, "weight": 1.0})
+
+ return {"nodes": nodes, "edges": edges}
+
+
def extract_csproj(path: Path) -> dict:
"""Extract packages, project refs, and target framework from a .csproj/.fsproj/.vbproj."""
import xml.etree.ElementTree as ET
@@ -11024,6 +11110,7 @@ _DISPATCH: dict[str, Any] = {
".dmm": extract_dmm,
".dmf": extract_dmf,
".sln": extract_sln,
+ ".slnx": extract_slnx,
".csproj": extract_csproj,
".fsproj": extract_csproj,
".vbproj": extract_csproj,
diff --git a/tests/fixtures/sample.slnx b/tests/fixtures/sample.slnx
new file mode 100644
index 0000000..7f57a5a
--- /dev/null
+++ b/tests/fixtures/sample.slnx
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py
index 17a1460..c653567 100644
--- a/tests/test_dotnet.py
+++ b/tests/test_dotnet.py
@@ -2,7 +2,7 @@
from pathlib import Path
import tempfile
import pytest
-from graphify.extract import extract_sln, extract_csproj, extract_razor
+from graphify.extract import extract_sln, extract_slnx, extract_csproj, extract_razor
FIXTURES = Path(__file__).parent / "fixtures"
@@ -37,6 +37,41 @@ def test_sln_project_dependency():
assert "imports" in _relations(r)
+# ── .slnx ────────────────────────────────────────────────────────────────────
+
+def test_slnx_extracts_projects():
+ r = extract_slnx(FIXTURES / "sample.slnx")
+ assert "error" not in r
+ labels = set(_labels(r))
+ assert "WebApi" in labels
+ assert "Domain" in labels
+ assert "Tests" in labels
+
+
+def test_slnx_contains_edges():
+ r = extract_slnx(FIXTURES / "sample.slnx")
+ contains = [e for e in r["edges"] if e["relation"] == "contains"]
+ assert len(contains) == 3
+
+
+def test_slnx_project_dependency():
+ r = extract_slnx(FIXTURES / "sample.slnx")
+ assert "imports" in _relations(r)
+
+
+def test_slnx_invalid_xml():
+ with tempfile.NamedTemporaryFile(suffix=".slnx", mode="w", delete=False) as f:
+ f.write("")
+ f.flush()
+ r = extract_slnx(Path(f.name))
+ assert "error" in r
+
+
+def test_slnx_missing_file():
+ r = extract_slnx(Path("/nonexistent/file.slnx"))
+ assert "error" in r
+
+
# ── .csproj ──────────────────────────────────────────────────────────────────
def test_csproj_packages():
@@ -115,11 +150,11 @@ def test_razor_missing_file():
def test_dispatch_table():
from graphify.extract import _get_extractor
- for ext in (".sln", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml"):
+ for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".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", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml"):
+ for ext in (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", ".razor", ".cshtml"):
assert ext in CODE_EXTENSIONS, f"{ext} missing"
diff --git a/uv.lock b/uv.lock
index d546481..ff119ff 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1146,7 +1146,7 @@ wheels = [
[[package]]
name = "graphifyy"
-version = "0.8.33"
+version = "0.8.35"
source = { editable = "." }
dependencies = [
{ name = "datasketch" },