modify harness framework

This commit is contained in:
김경종
2026-09-08 10:30:07 +09:00
parent 1239d5b9f6
commit 1a0f3c9aaf
68 changed files with 6729 additions and 957 deletions
@@ -0,0 +1 @@
"""Windows .NET 8 C# product validation support."""
@@ -0,0 +1 @@
""".NET validation command construction."""
@@ -0,0 +1,2 @@
class AdapterError(RuntimeError):
pass
@@ -0,0 +1,36 @@
from uuid import uuid4
from .base import AdapterError
from ..models import (CommandSpec, ProjectKind, ResultCheck, ResultCheckKind,
ValidationPlan, ValidationStep)
class DotnetAdapter:
def create_plan(self, root, selection, config, tools):
root = root.resolve()
if not config.dotnet.test_projects:
raise AdapterError("dotnet.testProjects must specify the product tests")
tests = tuple(dict.fromkeys(config.dotnet.test_projects))
targets = tuple(dict.fromkeys((selection.project_file, *tests)))
configuration = config.dotnet.configuration
steps = []
for action in ("restore", "build"):
for target in targets:
argv = (str(tools.dotnet), action, str(target))
# Restore and build must evaluate the same conditional references.
if action == "restore":
argv += (f"-p:Configuration={configuration}",)
else:
argv += ("-c", configuration, "--no-restore", "-warnaserror")
steps.append(ValidationStep(CommandSpec(argv, root, f"{action}: {target.name}")))
results = root / ".harness/test-results" / uuid4().hex
for index, target in enumerate(tests):
directory = results / str(index)
argv = (str(tools.dotnet), "test", str(target), "-c", configuration,
"--no-build", "--no-restore", "--logger", "trx",
"--results-directory", str(directory))
steps.append(ValidationStep(
CommandSpec(argv, root, f"test: {target.name}"),
(ResultCheck(ResultCheckKind.TRX_HAS_TESTS, directory),),
))
return ValidationPlan(ProjectKind.DOTNET, tuple(steps))
@@ -0,0 +1,81 @@
import json
from pathlib import Path, PureWindowsPath
from typing import Any
from .discovery import DiscoveryError, validate_csproj
from .models import DotnetConfig, HarnessConfig, TDDConfig
DEFAULT_TEST_PATTERNS = ("{stem}Tests.cs", "{stem}Test.cs")
class ConfigError(ValueError):
pass
def _mapping(value: Any, allowed: set[str], label: str) -> dict:
if not isinstance(value, dict):
raise ConfigError(f"{label} must be an object")
unknown = sorted(set(value) - allowed)
if unknown:
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}; use version 2 .NET configuration")
return value
def _string(value: Any, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{label} must be a non-empty string")
return value
def _array(value: Any, label: str, *, allow_empty=False) -> tuple[str, ...]:
if not isinstance(value, list) or (not value and not allow_empty):
raise ConfigError(f"{label} must be a {'non-empty ' if not allow_empty else ''}string array")
return tuple(_string(item, label) for item in value)
def _repo_path(root: Path, raw: str, label: str) -> Path:
raw = _string(raw, label)
windows = PureWindowsPath(raw)
if windows.drive or windows.root or Path(raw).is_absolute():
raise ConfigError(f"{label} must be repository-relative")
resolved = (root / raw.replace("\\", "/")).resolve()
if not resolved.is_relative_to(root):
raise ConfigError(f"{label} resolves outside the repository")
return resolved
def load_config(root: Path) -> HarnessConfig:
root = root.resolve()
path = root / ".harness/config.json"
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 2}
except (OSError, ValueError) as exc:
raise ConfigError(f"cannot read configuration {path}: {exc}") from exc
data = _mapping(data, {"version", "projectType", "dotnet", "tdd"}, "config")
if type(data.get("version")) is not int or data["version"] != 2:
raise ConfigError("version must be 2; rewrite legacy configuration using the .NET config example")
project_type = data.get("projectType", "auto")
if project_type not in ("auto", "dotnet"):
raise ConfigError("projectType must be auto or dotnet")
dotnet = _mapping(data.get("dotnet", {}), {"target", "configuration", "testProjects"}, "dotnet")
target = _repo_path(root, dotnet["target"], "dotnet.target") if "target" in dotnet else None
if target is not None and (target.suffix.lower() not in {".sln", ".csproj"} or not target.is_file()):
raise ConfigError("dotnet.target must be an existing .sln or SDK-style .csproj")
configuration = _string(dotnet.get("configuration", "Debug"), "dotnet.configuration")
tests = tuple(_repo_path(root, item, "dotnet.testProjects") for item in _array(dotnet["testProjects"], "dotnet.testProjects")) if "testProjects" in dotnet else ()
for test in tests:
try:
validate_csproj(test)
except DiscoveryError as exc:
raise ConfigError(f"dotnet.testProjects: {exc}") from exc
tdd = _mapping(data.get("tdd", {}), {"testRoots", "testPatterns", "exclude"}, "tdd")
roots = _array(tdd["testRoots"], "tdd.testRoots") if "testRoots" in tdd else ("tests",)
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in roots)
patterns = _array(tdd["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd else DEFAULT_TEST_PATTERNS
for pattern in patterns:
if "{stem}" not in pattern or not pattern.endswith(".cs") or any(char in pattern.replace("{stem}", "") for char in "{}/\\"):
raise ConfigError("tdd.testPatterns must be C# filenames containing only the {stem} placeholder")
exclude = _array(tdd["exclude"], "tdd.exclude", allow_empty=True) if "exclude" in tdd else ()
for item in exclude:
_repo_path(root, item, "tdd.exclude")
return HarnessConfig(2, project_type, DotnetConfig(target, configuration, tests), TDDConfig(test_roots, patterns, exclude))
@@ -0,0 +1,106 @@
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))
@@ -0,0 +1,78 @@
"""Validate the SDK-evaluated project-reference graph before restore."""
import json
import subprocess
import time
from collections import deque
from pathlib import Path
from .discovery import DiscoveryError, validate_csproj
from .models import CommandSpec, ResultCheck, ResultCheckKind, ValidationPlan, ValidationStep
from .process import ValidationFailure, execute_plan
def _project_path(root, path):
try:
path = path.resolve()
if not path.is_relative_to(root):
raise ValidationFailure(f"ProjectReference resolves outside the repository: {path}")
validate_csproj(path)
except (DiscoveryError, OSError, ValueError) as exc:
raise ValidationFailure(f"invalid ProjectReference {path}: {exc}") from exc
return path
def validate_project_graph(
root, selection, config, tools, *, deadline, clock=time.monotonic, run=subprocess.run,
):
root = root.resolve()
pending = deque((path, None) for path in
(*selection.projects, *config.dotnet.test_projects))
visited = set()
while pending:
project, framework = pending.popleft()
project = _project_path(root, project)
key = (project, framework)
if key in visited:
continue
visited.add(key)
argv = (
str(tools.dotnet), "msbuild", str(project),
"-getProperty:TargetFramework,TargetFrameworks",
"-getItem:ProjectReference",
f"-p:Configuration={config.dotnet.configuration}",
)
if framework is not None:
argv += (f"-p:TargetFramework={framework}",)
plan = ValidationPlan(selection.kind, (ValidationStep(
CommandSpec(argv, root, f"metadata: {project.name}"),
(ResultCheck(ResultCheckKind.PROJECT_IS_NET8),),
),))
result = execute_plan(plan, root, deadline=deadline, clock=clock, run=run)[0]
try:
data = json.loads(result.stdout)
references = data["Items"]["ProjectReference"]
if not isinstance(references, list):
raise ValueError("expected an array")
for reference in references:
fields = {key.casefold(): value for key, value in reference.items()}
if any(fields.get(name) for name in (
"additionalproperties", "globalpropertiestoremove",
"setconfiguration", "setplatform", "settargetframework",
)):
raise ValueError("custom reference properties are unsupported; use the shared Configuration and project TFM")
raw = fields.get("fullpath") or fields.get("identity")
if not isinstance(raw, str) or not raw.strip():
raise ValueError("reference must have FullPath or Identity")
child = _project_path(root, project.parent / Path(raw))
pending.append((child, None))
# Outer builds omit references conditioned on an individual TFM.
if framework is None:
targets = data["Properties"].get("TargetFrameworks", "")
for target in targets.split(";"):
if target.strip():
pending.append((project, target.strip()))
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(
f"invalid evaluated ProjectReference metadata for {project}: {exc}"
) from exc
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ProjectKind(Enum):
DOTNET = "dotnet"
class ResultCheckKind(Enum):
TRX_HAS_TESTS = "trx_has_tests"
PROJECT_IS_NET8 = "project_is_net8"
@dataclass(frozen=True)
class DotnetConfig:
target: Path | None = None
configuration: str = "Debug"
test_projects: tuple[Path, ...] = ()
@dataclass(frozen=True)
class TDDConfig:
test_roots: tuple[Path, ...]
test_patterns: tuple[str, ...]
exclude: tuple[str, ...] = ()
@dataclass(frozen=True)
class HarnessConfig:
version: int
project_type: str
dotnet: DotnetConfig
tdd: TDDConfig
@dataclass(frozen=True)
class ProjectSelection:
kind: ProjectKind
project_file: Path
projects: tuple[Path, ...] = ()
@dataclass(frozen=True)
class DiscoveryResult:
selection: ProjectSelection | None
@dataclass(frozen=True)
class Toolchain:
dotnet: Path
sdk_version: str
@dataclass(frozen=True)
class CommandSpec:
argv: tuple[str, ...]
cwd: Path
stage: str
timeout_seconds: int = 1800
@dataclass(frozen=True)
class ResultCheck:
kind: ResultCheckKind
path: Path | None = None
@dataclass(frozen=True)
class ValidationStep:
command: CommandSpec
checks: tuple[ResultCheck, ...] = ()
@dataclass(frozen=True)
class ValidationPlan:
project_kind: ProjectKind
steps: tuple[ValidationStep, ...]
@dataclass(frozen=True)
class CommandResult:
command: CommandSpec
returncode: int
stdout: str
stderr: str
@@ -0,0 +1,58 @@
import fnmatch
import os
from pathlib import Path
EXCLUDED_PARTS = {
"bin", "obj", ".git", ".vs", ".harness", "testresults",
"generated", "vendor", "third_party", "external",
}
def is_excluded_path(path: Path, root: Path, extra=()) -> bool:
root = root.resolve()
candidate = path if path.is_absolute() else root / path
try:
relative = candidate.relative_to(root)
resolved_relative = candidate.resolve().relative_to(root)
except ValueError:
return True
parts = tuple(part.lower() for part in relative.parts)
if any(part in EXCLUDED_PARTS for part in parts):
return True
if parts[:2] == ("tests", "fixtures"):
return True
resolved_parts = tuple(part.lower() for part in resolved_relative.parts)
if any(part in EXCLUDED_PARTS for part in resolved_parts) or resolved_parts[:2] == ("tests", "fixtures"):
return True
name = relative.as_posix().lower()
return any(
fnmatch.fnmatchcase(name, pattern.replace("\\", "/").lower())
or name.startswith(pattern.replace("\\", "/").lower().rstrip("/") + "/")
for pattern in extra
)
def iter_source_files(root: Path):
"""Walk repository inputs, pruning artifacts and external/cyclic links."""
root = root.resolve()
seen = set()
def fail(error):
raise error
for directory, directories, files in os.walk(root, followlinks=False, onerror=fail):
current = Path(directory)
resolved = current.resolve()
if resolved in seen or is_excluded_path(current, root):
directories[:] = []
continue
seen.add(resolved)
directories[:] = sorted(
name for name in directories
if not is_excluded_path(current / name, root)
and (current / name).resolve() not in seen
)
for name in sorted(files):
path = current / name
if not is_excluded_path(path, root):
yield path
@@ -0,0 +1,170 @@
import json
import locale
import re
import xml.etree.ElementTree as ET
import subprocess
import time
from .models import CommandResult, ResultCheckKind
DIAGNOSTIC_LIMIT = 8000
class ValidationFailure(RuntimeError):
pass
def _decode(value):
if value is None:
return ""
if isinstance(value, str):
return value
for encoding in ("utf-8", locale.getpreferredencoding(False)):
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", errors="replace")
def _check_result(check, result):
if check.kind is ResultCheckKind.PROJECT_IS_NET8:
try:
properties = json.loads(result.stdout)["Properties"]
target = properties.get("TargetFrameworks") or properties["TargetFramework"]
frameworks = target.split(";")
if not all(re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", item.strip()) for item in frameworks):
raise ValueError(f"expected net8.0 targets, got {target!r}")
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(f"cannot verify evaluated net8.0 target: {exc}") from exc
return
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
raise ValidationFailure("unsupported result check")
files = sorted(check.path.glob("*.trx"))
if not files:
raise ValidationFailure("test run produced no TRX results")
for path in files:
try:
if path.resolve().parent != check.path.resolve():
raise ValueError("TRX result is outside its result directory")
tree = ET.parse(path)
summary = tree.getroot().find("{*}ResultSummary")
if summary is None or summary.get("outcome") not in {"Completed", "Passed"}:
raise ValueError("test run did not complete successfully")
counters = summary.find("{*}Counters")
if counters is None:
raise ValueError("missing test counters")
counts = {key: int(value) for key, value in counters.attrib.items()}
if not (0 < counts["passed"] <= counts["executed"] <= counts["total"]):
raise ValueError("no executed passing tests or inconsistent counters")
if counts["failed"] or any(counts.get(key, 0) for key in
("error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected")):
raise ValueError("unsuccessful tests in TRX")
if any(value < 0 for value in counts.values()):
raise ValueError("negative test counters")
except (ET.ParseError, OSError, ValueError, KeyError) as exc:
raise ValidationFailure(f"invalid TRX {path.name}: {exc}") from exc
def _diagnostic(stdout, stderr):
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
def _command_context(argv, cwd):
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
def execute_plan(
plan,
root,
*,
env=None,
total_timeout_seconds=1800,
deadline=None,
run=subprocess.run,
clock=time.monotonic,
):
root = root.resolve()
if deadline is None:
deadline = clock() + total_timeout_seconds
results = []
for step in plan.steps:
cwd = step.command.cwd.resolve()
context = _command_context(step.command.argv, cwd)
try:
cwd.relative_to(root)
except ValueError as exc:
raise ValidationFailure(
f"{step.command.stage} working directory is outside the repository; "
f"{context}"
) from exc
for check in step.checks:
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
continue
if check.path is None:
raise ValidationFailure(f"{step.command.stage} missing results directory; {context}")
result_path = check.path.absolute()
if not result_path.resolve().is_relative_to(root) or result_path.resolve() != result_path:
raise ValidationFailure(f"{step.command.stage} results directory is outside repository or linked; {context}")
try:
result_path.mkdir(parents=True, exist_ok=False)
except OSError as exc:
raise ValidationFailure(f"{step.command.stage} requires a fresh results directory: {exc}; {context}") from exc
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(
f"{step.command.stage} timed out before it could start; {context}"
)
timeout = min(step.command.timeout_seconds, remaining)
try:
completed = run(
list(step.command.argv),
cwd=cwd,
env=env,
capture_output=True,
shell=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
stdout = _decode(exc.output)
stderr = _decode(exc.stderr)
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} timed out after {timeout} seconds; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
stdout = _decode(completed.stdout)
stderr = _decode(completed.stderr)
result = CommandResult(step.command, completed.returncode, stdout, stderr)
if completed.returncode != 0:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} failed with exit code {completed.returncode}; "
f"{context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message)
try:
for check in step.checks:
_check_result(check, result)
except ValidationFailure as exc:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} result check failed: {exc}; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
results.append(result)
return tuple(results)
@@ -0,0 +1,36 @@
from fnmatch import fnmatch
from .paths import is_excluded_path, iter_source_files
CS_SUFFIXES = frozenset({".cs"})
def is_test_file(path, config):
return any(path.is_relative_to(root) for root in config.test_roots)
def evaluate_paths(paths, root, config):
root = root.resolve()
candidates = None
for raw in paths:
path = raw.resolve()
if not path.is_relative_to(root):
return f"TDD GUARD: '{path}' is outside the repository"
if path.suffix.lower() not in CS_SUFFIXES:
continue
if is_excluded_path(path, root, config.exclude) or is_test_file(path, config):
continue
if candidates is None:
candidates = tuple(p for p in iter_source_files(root) if p.suffix.lower() == ".cs")
roots = (*config.test_roots, path.parent / "tests", path.parent / "test")
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
found = any(
candidate.is_relative_to(test_root)
and any(fnmatch(candidate.name if "/" not in pattern else candidate.relative_to(test_root).as_posix(), pattern)
for pattern in names)
for test_root in roots for candidate in candidates
)
if not found:
return (f"TDD GUARD: '{path.name}' requires an existing test such as '{names[0]}'. "
"Add the test in a configured test root first.")
return None
@@ -0,0 +1,41 @@
import re
import shutil
import subprocess
import time
from pathlib import Path
from .models import Toolchain
class ToolchainError(RuntimeError):
pass
def _decode(value):
return value if isinstance(value, str) else (value or b"").decode("utf-8", errors="replace")
def discover_toolchain(root: Path, *, which=shutil.which, run=subprocess.run, deadline=None, clock=time.monotonic) -> Toolchain:
root = root.resolve()
executable = which("dotnet")
if executable is None or not Path(executable).is_file():
raise ToolchainError("dotnet not found; install .NET SDK 8 and check PATH and global.json")
executable = Path(executable).resolve()
argv = [str(executable), "--version"]
context = f"argv={argv!r}; cwd={str(root)!r}; check .NET SDK 8 and global.json"
timeout = 1800 if deadline is None else min(1800, deadline - clock())
if timeout <= 0:
raise ToolchainError(f"SDK probe timed out before it could start; {context}")
try:
result = run(argv, cwd=root, capture_output=True, check=False, shell=False, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise ToolchainError(f"SDK probe timed out; {context}") from exc
except OSError as exc:
raise ToolchainError(f"SDK probe could not start: {exc}; {context}") from exc
if result.returncode != 0:
diagnostic = (_decode(result.stdout) + "\n" + _decode(result.stderr)).strip()[-8000:]
raise ToolchainError(f"SDK probe failed with exit code {result.returncode}; {context}; output tail: {diagnostic}")
version = _decode(result.stdout).strip()
if not re.fullmatch(r"8\.0\.\d+", version):
raise ToolchainError(f"expected stable .NET SDK 8, got {version!r}; {context}")
return Toolchain(executable, version)