Skip to content

Commit c8f20db

Browse files
author
murat.oguz
committed
Add support for parsing .csproj files and enhance repo URL validation
1 parent 0199236 commit c8f20db

3 files changed

Lines changed: 102 additions & 46 deletions

File tree

‎backend/app/services/file_analyzer.py‎

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,48 +30,95 @@ def parse_package_json(text: str) -> List[Dict[str, Any]]:
3030
deps.append({"name": name, "version": version})
3131
return deps
3232

33+
def parse_csproj_file(content: str) -> List[Dict[str, Any]]:
34+
"""Parse a .csproj file content and extract PackageReference entries.
3335
34-
def parse_requirements(text: str) -> List[Dict[str, Any]]:
36+
Returns a list of dicts with keys: `name` and `version` (version may be None).
37+
Handles both attribute-style (`<PackageReference Include="Foo" Version="1.2.3" />`)
38+
and nested `<Version>` child elements. Works with XML namespaces.
39+
"""
40+
try:
41+
root = ET.fromstring(content)
42+
except ET.ParseError:
43+
return []
44+
45+
deps: List[Dict[str, Any]] = []
46+
47+
def local_name(tag: str) -> str:
48+
return tag.split('}')[-1] if '}' in tag else tag
49+
50+
# Iterate through all elements and find PackageReference nodes
51+
for elem in root.iter():
52+
if local_name(elem.tag) != 'PackageReference':
53+
continue
54+
55+
# Name can be in Include or Update attribute
56+
name = elem.get('Include') or elem.get('Update')
57+
58+
# Version can be an attribute or a child <Version> element
59+
version = elem.get('Version')
60+
if version is None:
61+
for child in list(elem):
62+
if local_name(child.tag) == 'Version' and (child.text or '').strip():
63+
version = (child.text or '').strip()
64+
break
65+
66+
if name:
67+
deps.append({"name": name, "version": version})
68+
69+
return deps
70+
71+
def parse_requirements(text: str, filename: str="") -> List[Dict[str, Any]]:
3572
# Support two common formats:
3673
# 1) pip-style requirements (lines with optional ==version)
3774
# 2) NuGet packages.config XML
3875
text_stripped = text.strip()
39-
# Heuristic: if it looks like XML and contains <package ... />, parse as packages.config
40-
if text_stripped.startswith("<?xml") or "<packages" in text_stripped or "<package" in text_stripped:
41-
deps: List[Dict[str, Any]] = []
42-
try:
43-
root = ET.fromstring(text)
44-
# Handle <packages><package id="..." version="..." /></packages>
45-
for pkg in root.findall('.//package'):
46-
name = pkg.get('id') or pkg.get('Id') or pkg.get('name')
47-
version = pkg.get('version') or pkg.get('Version')
48-
if name:
49-
deps.append({"name": name, "version": version})
50-
except ET.ParseError:
51-
# Fall back to pip-style parsing if XML parsing fails
52-
deps = []
53-
for raw in text.splitlines():
54-
line = raw.strip()
55-
if not line or line.startswith("#"):
56-
continue
57-
if "==" in line:
58-
name, ver = line.split("==", 1)
59-
deps.append({"name": name.strip(), "version": ver.strip()})
60-
else:
61-
deps.append({"name": line, "version": None})
62-
return deps
63-
64-
# Default: pip-style requirements
65-
deps = []
66-
for raw in text.splitlines():
67-
line = raw.strip()
68-
if not line or line.startswith("#"):
69-
continue
70-
if "==" in line:
71-
name, ver = line.split("==", 1)
72-
deps.append({"name": name.strip(), "version": ver.strip()})
73-
else:
74-
deps.append({"name": line, "version": None})
76+
deps: List[Dict[str, Any]] = []
77+
78+
if filename and filename.lower().endswith(".csproj"):
79+
deps = parse_csproj_file(content=text_stripped)
80+
elif (filename and filename.lower().find("packages.config") != -1):
81+
return parse_packages_config(text)
82+
else:
83+
# Default: pip-style requirements
84+
deps = []
85+
for raw in text.splitlines():
86+
line = raw.strip()
87+
if not line or line.startswith("#"):
88+
continue
89+
if "==" in line:
90+
name, ver = line.split("==", 1)
91+
deps.append({"name": name.strip(), "version": ver.strip()})
92+
else:
93+
deps.append({"name": line, "version": None})
94+
return deps
95+
96+
97+
def parse_packages_config(text: str) -> List[Dict[str, Any]]:
98+
"""Parse a NuGet `packages.config` XML or fall back to pip-style lines.
99+
100+
Returns list of {name, version}.
101+
"""
102+
deps: List[Dict[str, Any]] = []
103+
try:
104+
root = ET.fromstring(text)
105+
# Handle <packages><package id="..." version="..." /></packages>
106+
for pkg in root.findall('.//package'):
107+
name = pkg.get('id') or pkg.get('Id') or pkg.get('name')
108+
version = pkg.get('version') or pkg.get('Version')
109+
if name:
110+
deps.append({"name": name, "version": version})
111+
except ET.ParseError:
112+
# Fall back to pip-style parsing if XML parsing fails
113+
for raw in text.splitlines():
114+
line = raw.strip()
115+
if not line or line.startswith("#"):
116+
continue
117+
if "==" in line:
118+
name, ver = line.split("==", 1)
119+
deps.append({"name": name.strip(), "version": ver.strip()})
120+
else:
121+
deps.append({"name": line, "version": None})
75122
return deps
76123

77124

@@ -88,7 +135,7 @@ def analyze_file(filename: str, content: str) -> Dict[str, Any]:
88135
elif manager == "maven":
89136
result["ecosystem"] = "maven"
90137
elif manager == "nuget":
91-
result["dependencies"] = parse_requirements(content)
138+
result["dependencies"] = parse_requirements(content, filename=filename)
92139
result["ecosystem"] = "nuget"
93140
else:
94141
result["ecosystem"] = "unknown"

‎backend/app/services/repo_scanner.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,17 @@
1818
"pom.xml", "build.gradle", "build.gradle.kts",
1919
# .NET
2020
"packages.config",
21-
# TODO: Disable until .csproj support is added
22-
# "csproj",
21+
".csproj",
2322
# Go
2423
"go.mod", "vendor/modules.txt",
2524
}
2625

2726

2827
def is_dependency_file(filename: str) -> bool:
2928
lower = filename.lower()
30-
# TODO: Disable until .csproj support is added
31-
# if lower.endswith(".csproj"):
32-
# return True
29+
30+
if lower.endswith(".csproj"):
31+
return True
3332
return lower in DEP_FILES
3433

3534

‎backend/app/views/library_view.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ async def handle_repo_clone(payload: dict):
294294
repo_url = payload.get('url')
295295
if not repo_url:
296296
raise HTTPException(status_code=400, detail='url is required')
297+
if not isinstance(repo_url, str):
298+
raise HTTPException(status_code=400, detail='url must be a string')
299+
# ensure static type checkers see a plain str
300+
repo_url = str(repo_url)
297301

298302
try:
299303
root = clone_repository(repo_url)
@@ -329,9 +333,12 @@ async def handle_repo_list_packages(payload: dict):
329333
summaries = list_repository_packages(root)
330334
return {"root": root, "files": summaries}
331335
# else clone then list
332-
root = clone_repository(repo_url)
333-
summaries = list_repository_packages(root)
334-
return {"url": repo_url, "root": root, "files": summaries}
336+
elif repo_url:
337+
if not isinstance(repo_url, str):
338+
raise HTTPException(status_code=400, detail='url must be a string')
339+
root = clone_repository(str(repo_url))
340+
summaries = list_repository_packages(root)
341+
return {"url": repo_url, "root": root, "files": summaries}
335342
except Exception as error:
336343
raise HTTPException(status_code=500, detail=str(error))
337344

@@ -346,6 +353,9 @@ async def handle_repo_scan_highest_risk(payload: dict):
346353
repo_url = payload.get('url')
347354
if not repo_url:
348355
raise HTTPException(status_code=400, detail='url is required')
356+
if not isinstance(repo_url, str):
357+
raise HTTPException(status_code=400, detail='url must be a string')
358+
repo_url = str(repo_url)
349359

350360
client = get_mcp_http_client()
351361
if not client:

0 commit comments

Comments
 (0)