mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
add .NET project file support (.sln, .csproj, .fsproj, .vbproj, .razor, .cshtml)
Adds extract_sln, extract_csproj, and extract_razor extractors. Captures NuGet package refs, project-to-project dependencies, target frameworks, SDK attribute, @using/@inject/@inherits/@model directives, Blazor component refs, and @code methods. Resolves relative project paths to absolute paths so sln/csproj nodes link correctly when the graph is assembled. Closes #515. Co-Authored-By: aksrathore <aksrathore@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -211,7 +211,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
|
||||
|
||||
| Type | Extensions |
|
||||
|------|-----------|
|
||||
| Code (31 languages) | `.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 .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json` |
|
||||
| Code (32 languages) | `.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 .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .sln .csproj .fsproj .vbproj .razor .cshtml` |
|
||||
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` |
|
||||
| Office | `.docx .xlsx` (requires `pip install graphifyy[office]`) |
|
||||
| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; requires `gws` auth and `--google-workspace`; Sheets need `pip install graphifyy[google]`) |
|
||||
|
||||
+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', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.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', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml'}
|
||||
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
|
||||
PAPER_EXTENSIONS = {'.pdf'}
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
@@ -7710,6 +7710,305 @@ def extract_bash(path: Path) -> dict:
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
# ── .NET project files (.sln, .csproj, .razor) ──────────────────────────────
|
||||
|
||||
def extract_sln(path: Path) -> dict:
|
||||
"""Extract projects and inter-project dependencies from a .sln file."""
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
|
||||
|
||||
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)
|
||||
|
||||
_PROJECT_RE = re.compile(
|
||||
r'Project\("[^"]*"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"'
|
||||
)
|
||||
_DEP_RE = re.compile(r'\{([0-9a-fA-F-]+)\}\s*=\s*\{([0-9a-fA-F-]+)\}')
|
||||
|
||||
guid_to_nid: dict[str, str] = {}
|
||||
|
||||
for m in _PROJECT_RE.finditer(src):
|
||||
proj_name = m.group(1)
|
||||
proj_path = m.group(2).replace("\\", "/")
|
||||
proj_guid = m.group(3).strip("{}")
|
||||
|
||||
try:
|
||||
abs_proj = str((path.parent / proj_path).resolve())
|
||||
except Exception:
|
||||
abs_proj = proj_path
|
||||
proj_nid = _make_id(abs_proj)
|
||||
if proj_nid and proj_nid not in seen_ids:
|
||||
seen_ids.add(proj_nid)
|
||||
nodes.append({"id": proj_nid, "label": proj_name,
|
||||
"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_guid:
|
||||
guid_to_nid[proj_guid.lower()] = proj_nid
|
||||
|
||||
in_dep_section = False
|
||||
current_proj_guid: str | None = None
|
||||
_PROJECT_LINE_RE = re.compile(r'Project\("[^"]*"\)\s*=\s*"[^"]+"\s*,\s*"[^"]+"\s*,\s*"\{([^}]+)\}"')
|
||||
for line in src.splitlines():
|
||||
proj_line_m = _PROJECT_LINE_RE.search(line)
|
||||
if proj_line_m:
|
||||
current_proj_guid = proj_line_m.group(1).lower()
|
||||
continue
|
||||
if line.strip() == "EndProject":
|
||||
current_proj_guid = None
|
||||
continue
|
||||
if "ProjectSection(ProjectDependencies)" in line:
|
||||
in_dep_section = True
|
||||
continue
|
||||
if in_dep_section and "EndProjectSection" in line:
|
||||
in_dep_section = False
|
||||
continue
|
||||
if in_dep_section and current_proj_guid:
|
||||
dep_m = _DEP_RE.search(line)
|
||||
if dep_m:
|
||||
to_guid = dep_m.group(1).lower()
|
||||
from_nid = guid_to_nid.get(current_proj_guid)
|
||||
to_nid = guid_to_nid.get(to_guid)
|
||||
if from_nid and to_nid and from_nid != to_nid:
|
||||
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
|
||||
|
||||
try:
|
||||
src = path.read_bytes()
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
|
||||
|
||||
if len(src) > 2_097_152:
|
||||
return {"nodes": [], "edges": [], "error": "project file too large"}
|
||||
|
||||
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 = ""
|
||||
root_tag = tree.tag
|
||||
if root_tag.startswith("{"):
|
||||
ns = root_tag.split("}")[0] + "}"
|
||||
|
||||
def find_all(tag: str):
|
||||
return tree.iter(f"{ns}{tag}")
|
||||
|
||||
for tf in find_all("TargetFramework"):
|
||||
if tf.text:
|
||||
fw_nid = _make_id("framework", tf.text.strip())
|
||||
if fw_nid and fw_nid not in seen_ids:
|
||||
seen_ids.add(fw_nid)
|
||||
nodes.append({"id": fw_nid, "label": tf.text.strip(),
|
||||
"file_type": "concept", "source_file": str_path,
|
||||
"source_location": None})
|
||||
edges.append({"source": file_nid, "target": fw_nid,
|
||||
"relation": "references", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
for tf in find_all("TargetFrameworks"):
|
||||
if tf.text:
|
||||
for fw in tf.text.strip().split(";"):
|
||||
fw = fw.strip()
|
||||
if fw:
|
||||
fw_nid = _make_id("framework", fw)
|
||||
if fw_nid and fw_nid not in seen_ids:
|
||||
seen_ids.add(fw_nid)
|
||||
nodes.append({"id": fw_nid, "label": fw,
|
||||
"file_type": "concept", "source_file": str_path,
|
||||
"source_location": None})
|
||||
edges.append({"source": file_nid, "target": fw_nid,
|
||||
"relation": "references", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
for pkg in find_all("PackageReference"):
|
||||
name = pkg.get("Include") or pkg.get("include") or ""
|
||||
version = pkg.get("Version") or pkg.get("version") or ""
|
||||
if not name:
|
||||
continue
|
||||
pkg_nid = _make_id("nuget", name)
|
||||
label = f"{name} ({version})" if version else name
|
||||
if pkg_nid and pkg_nid not in seen_ids:
|
||||
seen_ids.add(pkg_nid)
|
||||
nodes.append({"id": pkg_nid, "label": label,
|
||||
"file_type": "code", "source_file": str_path,
|
||||
"source_location": None})
|
||||
edges.append({"source": file_nid, "target": pkg_nid,
|
||||
"relation": "imports", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
for proj in find_all("ProjectReference"):
|
||||
ref_path = proj.get("Include") or proj.get("include") or ""
|
||||
if not ref_path:
|
||||
continue
|
||||
ref_path_norm = ref_path.replace("\\", "/")
|
||||
try:
|
||||
abs_ref = str((path.parent / ref_path_norm).resolve())
|
||||
except Exception:
|
||||
abs_ref = ref_path_norm
|
||||
proj_nid = _make_id(abs_ref)
|
||||
if proj_nid and proj_nid not in seen_ids:
|
||||
seen_ids.add(proj_nid)
|
||||
proj_label = Path(ref_path_norm).name
|
||||
nodes.append({"id": proj_nid, "label": proj_label,
|
||||
"file_type": "code", "source_file": abs_ref,
|
||||
"source_location": None})
|
||||
edges.append({"source": file_nid, "target": proj_nid,
|
||||
"relation": "imports", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
sdk = tree.get("Sdk") or ""
|
||||
if sdk:
|
||||
sdk_nid = _make_id("sdk", sdk)
|
||||
if sdk_nid and sdk_nid not in seen_ids:
|
||||
seen_ids.add(sdk_nid)
|
||||
nodes.append({"id": sdk_nid, "label": sdk,
|
||||
"file_type": "concept", "source_file": str_path,
|
||||
"source_location": None})
|
||||
edges.append({"source": file_nid, "target": sdk_nid,
|
||||
"relation": "references", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def extract_razor(path: Path) -> dict:
|
||||
"""Extract directives, component refs, and @code methods from .razor/.cshtml."""
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
|
||||
|
||||
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)
|
||||
|
||||
def _add_ref(target_name: str, relation: str, line: int) -> None:
|
||||
tgt_nid = _make_id(target_name)
|
||||
if not tgt_nid:
|
||||
return
|
||||
if tgt_nid not in seen_ids:
|
||||
seen_ids.add(tgt_nid)
|
||||
nodes.append({"id": tgt_nid, "label": target_name,
|
||||
"file_type": "code", "source_file": str_path,
|
||||
"source_location": f"L{line}"})
|
||||
edges.append({"source": file_nid, "target": tgt_nid,
|
||||
"relation": relation, "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "source_location": f"L{line}",
|
||||
"weight": 1.0})
|
||||
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
m = re.match(r'@using\s+([\w.]+)', line)
|
||||
if m:
|
||||
_add_ref(m.group(1), "imports", i)
|
||||
continue
|
||||
|
||||
m = re.match(r'@inject\s+([\w.<>\[\]]+)\s+(\w+)', line)
|
||||
if m:
|
||||
_add_ref(m.group(1), "imports", i)
|
||||
continue
|
||||
|
||||
m = re.match(r'@inherits\s+([\w.<>\[\]]+)', line)
|
||||
if m:
|
||||
_add_ref(m.group(1), "inherits", i)
|
||||
continue
|
||||
|
||||
m = re.match(r'@model\s+([\w.<>\[\]]+)', line)
|
||||
if m:
|
||||
_add_ref(m.group(1), "references", i)
|
||||
continue
|
||||
|
||||
m = re.match(r'@page\s+"([^"]+)"', line)
|
||||
if m:
|
||||
route = m.group(1)
|
||||
route_nid = _make_id("route", route)
|
||||
if route_nid and route_nid not in seen_ids:
|
||||
seen_ids.add(route_nid)
|
||||
nodes.append({"id": route_nid, "label": f"route:{route}",
|
||||
"file_type": "concept", "source_file": str_path,
|
||||
"source_location": f"L{i}"})
|
||||
edges.append({"source": file_nid, "target": route_nid,
|
||||
"relation": "references", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
continue
|
||||
|
||||
_COMPONENT_RE = re.compile(r'<([A-Z][A-Za-z0-9]+)[\s/>]')
|
||||
_HTML_TAGS = frozenset({
|
||||
"DOCTYPE", "Html", "Head", "Body", "Div", "Span", "Table", "Form",
|
||||
"Input", "Button", "Select", "Option", "Label", "Textarea",
|
||||
"Script", "Style", "Link", "Meta", "Title", "Header", "Footer",
|
||||
"Nav", "Main", "Section", "Article", "Aside",
|
||||
})
|
||||
for m in _COMPONENT_RE.finditer(src):
|
||||
comp_name = m.group(1)
|
||||
if comp_name in _HTML_TAGS:
|
||||
continue
|
||||
line_num = src[:m.start()].count("\n") + 1
|
||||
_add_ref(comp_name, "calls", line_num)
|
||||
|
||||
_CODE_BLOCK_RE = re.compile(r'@code\s*\{', re.MULTILINE)
|
||||
for m in _CODE_BLOCK_RE.finditer(src):
|
||||
block_start = m.end()
|
||||
depth = 1
|
||||
pos = block_start
|
||||
while pos < len(src) and depth > 0:
|
||||
if src[pos] == '{':
|
||||
depth += 1
|
||||
elif src[pos] == '}':
|
||||
depth -= 1
|
||||
pos += 1
|
||||
code_block = src[block_start:pos - 1] if depth == 0 else ""
|
||||
|
||||
_METHOD_RE = re.compile(
|
||||
r'(?:public|private|protected|internal|static|async|override|virtual|abstract)\s+'
|
||||
r'[\w<>\[\],\s]+\s+(\w+)\s*\('
|
||||
)
|
||||
for mm in _METHOD_RE.finditer(code_block):
|
||||
method_name = mm.group(1)
|
||||
abs_pos = block_start + mm.start()
|
||||
method_line = src[:abs_pos].count("\n") + 1
|
||||
method_nid = _make_id(_file_stem(path), method_name)
|
||||
if method_nid and method_nid not in seen_ids:
|
||||
seen_ids.add(method_nid)
|
||||
nodes.append({"id": method_nid, "label": method_name,
|
||||
"file_type": "code", "source_file": str_path,
|
||||
"source_location": f"L{method_line}"})
|
||||
edges.append({"source": file_nid, "target": method_nid,
|
||||
"relation": "contains", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "weight": 1.0})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def extract_json(path: Path) -> dict:
|
||||
"""Extract top-level keys, nested structure, and dependency edges from a .json file."""
|
||||
_JSON_MAX_BYTES = 1_048_576 # 1 MiB — skip large fixture dumps / GeoJSON blobs
|
||||
@@ -7922,6 +8221,12 @@ _DISPATCH: dict[str, Any] = {
|
||||
".sh": extract_bash,
|
||||
".bash": extract_bash,
|
||||
".json": extract_json,
|
||||
".sln": extract_sln,
|
||||
".csproj": extract_csproj,
|
||||
".fsproj": extract_csproj,
|
||||
".vbproj": extract_csproj,
|
||||
".razor": extract_razor,
|
||||
".cshtml": extract_razor,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
@page "/counter"
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MyApp.Services
|
||||
@inject ICounterService CounterService
|
||||
@inject NavigationManager Navigation
|
||||
@inherits ComponentBase
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p>Current count: @currentCount</p>
|
||||
|
||||
<Button OnClick="IncrementCount">Click me</Button>
|
||||
<WeatherDisplay City="@city" />
|
||||
<DataGrid TItem="CounterRecord" Items="@records" />
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
private string city = "Seattle";
|
||||
private List<CounterRecord> records = new();
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
CounterService.Increment();
|
||||
}
|
||||
|
||||
public async Task LoadData()
|
||||
{
|
||||
records = await CounterService.GetRecords();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "src\WebApi\WebApi.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {B2C3D4E5-F6A7-8901-BCDE-F12345678901}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "tests\Tests\Tests.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for .NET project file extraction (.sln, .csproj, .razor)."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import pytest
|
||||
from graphify.extract import extract_sln, extract_csproj, extract_razor
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _labels(r):
|
||||
return [n["label"] for n in r["nodes"]]
|
||||
|
||||
|
||||
def _relations(r):
|
||||
return {e["relation"] for e in r["edges"]}
|
||||
|
||||
|
||||
# ── .sln ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sln_extracts_projects():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
assert "error" not in r
|
||||
labels = set(_labels(r))
|
||||
assert "WebApi" in labels
|
||||
assert "Domain" in labels
|
||||
assert "Tests" in labels
|
||||
|
||||
|
||||
def test_sln_contains_edges():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
contains = [e for e in r["edges"] if e["relation"] == "contains"]
|
||||
assert len(contains) == 3
|
||||
|
||||
|
||||
def test_sln_project_dependency():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
assert "imports" in _relations(r)
|
||||
|
||||
|
||||
# ── .csproj ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_csproj_packages():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert "error" not in r
|
||||
labels = _labels(r)
|
||||
assert any("MediatR" in l for l in labels)
|
||||
assert any("FluentValidation" in l for l in labels)
|
||||
assert any("Swashbuckle" in l for l in labels)
|
||||
|
||||
|
||||
def test_csproj_project_references():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
imports = [e for e in r["edges"] if e["relation"] == "imports"]
|
||||
assert len(imports) == 6 # 4 packages + 2 project refs
|
||||
|
||||
|
||||
def test_csproj_target_framework():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert "net8.0" in _labels(r)
|
||||
|
||||
|
||||
def test_csproj_sdk():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert "Microsoft.NET.Sdk.Web" in _labels(r)
|
||||
|
||||
|
||||
def test_csproj_invalid_xml():
|
||||
with tempfile.NamedTemporaryFile(suffix=".csproj", mode="w", delete=False) as f:
|
||||
f.write("<Project><Invalid></Project>")
|
||||
f.flush()
|
||||
r = extract_csproj(Path(f.name))
|
||||
assert "error" in r
|
||||
|
||||
|
||||
# ── .razor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_razor_using_and_inject():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "error" not in r
|
||||
targets = {e["target"] for e in r["edges"] if e["relation"] == "imports"}
|
||||
assert any("microsoft" in t for t in targets)
|
||||
assert any("counterservice" in t.lower() for t in targets)
|
||||
|
||||
|
||||
def test_razor_components():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
targets = {e["target"] for e in r["edges"] if e["relation"] == "calls"}
|
||||
assert any("weatherdisplay" in t for t in targets)
|
||||
assert any("datagrid" in t for t in targets)
|
||||
|
||||
|
||||
def test_razor_page_route():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert any("/counter" in l for l in _labels(r))
|
||||
|
||||
|
||||
def test_razor_inherits():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "inherits" in _relations(r)
|
||||
|
||||
|
||||
def test_razor_code_methods():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
labels = _labels(r)
|
||||
assert "IncrementCount" in labels
|
||||
assert "LoadData" in labels
|
||||
|
||||
|
||||
def test_razor_missing_file():
|
||||
r = extract_razor(Path("/nonexistent/file.razor"))
|
||||
assert "error" in r
|
||||
|
||||
|
||||
# ── dispatch & detect integration ────────────────────────────────────────────
|
||||
|
||||
def test_dispatch_table():
|
||||
from graphify.extract import _get_extractor
|
||||
for ext in (".sln", ".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"):
|
||||
assert ext in CODE_EXTENSIONS, f"{ext} missing"
|
||||
+74
-2
@@ -1,4 +1,4 @@
|
||||
"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS."""
|
||||
"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files."""
|
||||
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_groovy, extract_sln, extract_csproj, extract_razor,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
@@ -1058,3 +1058,75 @@ def test_groovy_spock_no_dangling_edges():
|
||||
node_ids = {n["id"] for n in r["nodes"]}
|
||||
for e in r["edges"]:
|
||||
assert e["source"] in node_ids
|
||||
|
||||
|
||||
# -- .NET project files (.sln, .csproj, .razor) -------------------------------
|
||||
|
||||
def test_sln_no_error():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
assert "error" not in r
|
||||
|
||||
def test_sln_finds_projects():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
labels = _labels(r)
|
||||
assert any("WebApi" in l for l in labels)
|
||||
assert any("Domain" in l for l in labels)
|
||||
|
||||
def test_sln_contains_edges():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
assert "contains" in _relations(r)
|
||||
|
||||
def test_sln_project_dependency_edges():
|
||||
r = extract_sln(FIXTURES / "sample.sln")
|
||||
assert "imports" in _relations(r)
|
||||
|
||||
def test_csproj_no_error():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert "error" not in r
|
||||
|
||||
def test_csproj_finds_packages():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
labels = _labels(r)
|
||||
assert any("MediatR" in l for l in labels)
|
||||
assert any("FluentValidation" in l for l in labels)
|
||||
|
||||
def test_csproj_finds_project_references():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
labels = _labels(r)
|
||||
assert any("Domain.csproj" in l for l in labels)
|
||||
|
||||
def test_csproj_finds_target_framework():
|
||||
r = extract_csproj(FIXTURES / "sample.csproj")
|
||||
assert any("net8.0" in l for l in _labels(r))
|
||||
|
||||
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_razor_no_error():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "error" not in r
|
||||
|
||||
def test_razor_finds_using_directives():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "imports" in _relations(r)
|
||||
|
||||
def test_razor_finds_component_references():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "calls" in _relations(r)
|
||||
|
||||
def test_razor_finds_inherits():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
assert "inherits" in _relations(r)
|
||||
|
||||
def test_razor_finds_code_block_methods():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
labels = _labels(r)
|
||||
assert any("IncrementCount" in l for l in labels)
|
||||
assert any("LoadData" in l for l in labels)
|
||||
|
||||
def test_razor_no_dangling_edges():
|
||||
r = extract_razor(FIXTURES / "sample.razor")
|
||||
node_ids = {n["id"] for n in r["nodes"]}
|
||||
for e in r["edges"]:
|
||||
assert e["source"] in node_ids
|
||||
|
||||
Reference in New Issue
Block a user