107 lines
5.1 KiB
Python
107 lines
5.1 KiB
Python
import re
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path, PureWindowsPath
|
|
|
|
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
|
|
from .paths import iter_source_files
|
|
|
|
|
|
class DiscoveryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def validate_csproj(path: Path) -> None:
|
|
if path.suffix.lower() != ".csproj" or not path.is_file():
|
|
raise DiscoveryError(f"expected an existing SDK-style .csproj: {path}")
|
|
try:
|
|
project = ET.parse(path).getroot()
|
|
except (ET.ParseError, OSError) as exc:
|
|
raise DiscoveryError(f"cannot read project {path}: {exc}") from exc
|
|
name = lambda element: element.tag.rsplit("}", 1)[-1]
|
|
if name(project) != "Project" or not (
|
|
project.get("Sdk", "").strip()
|
|
or any(name(child) == "Sdk" and child.get("Name", "").strip() for child in project)
|
|
):
|
|
raise DiscoveryError(f"project must use SDK style; .NET Framework is unsupported: {path}")
|
|
for element in project.iter():
|
|
tag = name(element)
|
|
value = (element.text or "").strip()
|
|
if tag == "TargetFrameworkVersion" and value:
|
|
raise DiscoveryError(f".NET Framework metadata is unsupported: {path}")
|
|
if tag in {"TargetFramework", "TargetFrameworks"}:
|
|
# Build evaluates inherited properties and expressions. This preflight
|
|
# rejects explicit incompatible values rather than guessing expansion.
|
|
for target in value.split(";"):
|
|
target = target.strip()
|
|
if target and "$(" not in target and not re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", target):
|
|
raise DiscoveryError(f"only net8.0 targets are supported; found {target!r}: {path}")
|
|
|
|
|
|
def _validate_solution(path: Path, root: Path) -> tuple[Path, ...]:
|
|
try:
|
|
content = path.read_text(encoding="utf-8-sig")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise DiscoveryError(f"cannot read solution {path}: {exc}") from exc
|
|
if "Microsoft Visual Studio Solution File" not in content:
|
|
raise DiscoveryError(f"invalid .sln file: {path}")
|
|
projects = []
|
|
folder_types = {"66A26720-8FB5-11D2-AA7E-00C04F688DDE", "2150E333-8FDC-42A3-9474-1A3956D46DE8"}
|
|
csharp_types = {"FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", "9A19103F-16F7-4668-BE54-9A1E7A4F7556"}
|
|
for line in content.splitlines():
|
|
if not line.lstrip().startswith("Project("):
|
|
continue
|
|
match = re.match(r'\s*Project\("\{([^}]+)\}"\)\s*=\s*"[^"]*",\s*"([^"]+)"', line)
|
|
if not match:
|
|
raise DiscoveryError(f"invalid project entry in solution: {path}")
|
|
kind, reference = match.groups()
|
|
if kind.upper() in folder_types:
|
|
continue
|
|
if kind.upper() not in csharp_types:
|
|
raise DiscoveryError(f"unsupported project type in solution: {reference}")
|
|
windows_path = PureWindowsPath(reference)
|
|
if windows_path.drive or windows_path.root or Path(reference).is_absolute():
|
|
raise DiscoveryError(f"solution project must be repository-relative: {reference}")
|
|
candidate = (path.parent / reference.replace("\\", "/")).resolve()
|
|
if not candidate.is_relative_to(root):
|
|
raise DiscoveryError(f"solution project resolves outside repository: {reference}")
|
|
validate_csproj(candidate)
|
|
projects.append(candidate)
|
|
if not projects:
|
|
raise DiscoveryError(f"solution has no supported C# projects: {path}")
|
|
return tuple(projects)
|
|
|
|
|
|
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
|
|
root = root.resolve()
|
|
target = config.dotnet.target
|
|
if target is None:
|
|
files = tuple(iter_source_files(root))
|
|
solutions = [path for path in files if path.parent == root and path.suffix.lower() == ".sln"]
|
|
candidates = solutions or [path for path in files if path.suffix.lower() == ".csproj"]
|
|
if len(candidates) > 1:
|
|
raise DiscoveryError("multiple project candidates; set dotnet.target")
|
|
if candidates:
|
|
target = candidates[0]
|
|
elif config.project_type == "dotnet" or any(
|
|
path.suffix.lower() in {".cs", ".sln", ".slnx", ".vcxproj", ".fsproj", ".vbproj"}
|
|
for path in files
|
|
):
|
|
raise DiscoveryError("no supported C# metadata found; set dotnet.target to a .sln or SDK-style .csproj")
|
|
else:
|
|
return DiscoveryResult(None)
|
|
target = target.resolve()
|
|
if not target.is_relative_to(root) or not target.is_file():
|
|
raise DiscoveryError(f"project target must be an existing repository file: {target}")
|
|
if target.suffix.lower() == ".sln":
|
|
projects = _validate_solution(target, root)
|
|
else:
|
|
validate_csproj(target)
|
|
projects = (target,)
|
|
if not config.dotnet.test_projects:
|
|
raise DiscoveryError("product validation requires an explicit non-empty dotnet.testProjects list")
|
|
for test_project in config.dotnet.test_projects:
|
|
if not test_project.resolve().is_relative_to(root):
|
|
raise DiscoveryError(f"test project resolves outside repository: {test_project}")
|
|
validate_csproj(test_project)
|
|
return DiscoveryResult(ProjectSelection(ProjectKind.DOTNET, target, projects))
|