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:
co-authored by
bakgaard
parent
42d1b8d02f
commit
29e57cd295
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+7
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user