feat: add .slnx solution file support (#1189)

Adds support for the XML-based `.slnx` solution format (VS 2022 17.13+ replacement for `.sln`). Extracts project references as `contains` edges and build dependencies as `imports` edges. XXE-protected XML parsing with size cap. Wired into `_DISPATCH` and `CODE_EXTENSIONS`. 6 new tests passing.

Co-authored-by: bakgaard <bakgaard@users.noreply.github.com>
This commit is contained in:
Rasmus Bækgaard
2026-06-08 10:35:32 +01:00
committed by GitHub
co-authored by bakgaard
parent 42d1b8d02f
commit 29e57cd295
6 changed files with 136 additions and 7 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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'}
+88 -1
View File
@@ -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 ``<Project Path="..."/>`` elements (optionally nested inside
``<Folder>`` elements) and build-order dependencies as ``<BuildDependency
Project="..."/>`` 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. <Folder>).
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,
+7
View File
@@ -0,0 +1,7 @@
<Solution>
<Project Path="src/Domain/Domain.csproj" />
<Project Path="src/WebApi/WebApi.csproj">
<BuildDependency Project="src/Domain/Domain.csproj" />
</Project>
<Project Path="tests/Tests/Tests.csproj" />
</Solution>
+38 -3
View File
@@ -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("<Solution><Project></Solution>")
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"
Generated
+1 -1
View File
@@ -1146,7 +1146,7 @@ wheels = [
[[package]]
name = "graphifyy"
version = "0.8.33"
version = "0.8.35"
source = { editable = "." }
dependencies = [
{ name = "datasketch" },