diff --git a/docs/taskmaster/status.json b/docs/taskmaster/status.json index 36fa33a..a75fc41 100644 --- a/docs/taskmaster/status.json +++ b/docs/taskmaster/status.json @@ -2,8 +2,8 @@ "project": "mcilspy-code-review-fixes", "created": "2025-02-08T00:00:00Z", "domains": { - "security": { "status": "ready", "branch": "fix/security", "priority": 1 }, - "architecture": { "status": "ready", "branch": "fix/architecture", "priority": 2 }, + "security": { "status": "merged", "branch": "fix/security", "priority": 1 }, + "architecture": { "status": "merged", "branch": "fix/architecture", "priority": 2 }, "performance": { "status": "ready", "branch": "fix/performance", "priority": 3 }, "testing": { "status": "ready", "branch": "fix/testing", "priority": 4 } }, diff --git a/src/mcilspy/constants.py b/src/mcilspy/constants.py new file mode 100644 index 0000000..cae9632 --- /dev/null +++ b/src/mcilspy/constants.py @@ -0,0 +1,49 @@ +"""Constants and configuration values for mcilspy. + +This module centralizes all timeouts, limits, and magic numbers used throughout +the codebase. Import from here rather than hardcoding values. +""" + +# ============================================================================= +# Subprocess Timeouts +# ============================================================================= + +# Maximum time to wait for ilspycmd decompilation (in seconds) +# Large assemblies or corrupted files may take longer +DECOMPILE_TIMEOUT_SECONDS: float = 300.0 # 5 minutes + +# ============================================================================= +# Output Limits +# ============================================================================= + +# Maximum characters to display from subprocess output in error messages +MAX_ERROR_OUTPUT_CHARS: int = 1000 + +# Maximum line length when displaying code snippets (truncate longer lines) +MAX_LINE_LENGTH: int = 200 + +# Maximum unparsed lines to log before suppressing (avoid log spam) +MAX_UNPARSED_LOG_LINES: int = 3 + +# Preview length for unparsed line debug messages +UNPARSED_LINE_PREVIEW_LENGTH: int = 100 + +# ============================================================================= +# Search Limits +# ============================================================================= + +# Default maximum results for search operations +DEFAULT_MAX_SEARCH_RESULTS: int = 100 + +# Maximum matches to display per type in grouped results +MAX_MATCHES_PER_TYPE: int = 20 + +# ============================================================================= +# Tool Identifiers (for ilspycmd CLI) +# ============================================================================= + +# Default entity types for list operations +DEFAULT_ENTITY_TYPES: list[str] = ["class"] + +# All supported entity types +ALL_ENTITY_TYPES: list[str] = ["class", "interface", "struct", "delegate", "enum"] diff --git a/src/mcilspy/ilspy_wrapper.py b/src/mcilspy/ilspy_wrapper.py index 6f02a82..85d42d6 100644 --- a/src/mcilspy/ilspy_wrapper.py +++ b/src/mcilspy/ilspy_wrapper.py @@ -9,6 +9,11 @@ import tempfile from pathlib import Path from typing import Any +from .constants import ( + DECOMPILE_TIMEOUT_SECONDS, + MAX_UNPARSED_LOG_LINES, + UNPARSED_LINE_PREVIEW_LENGTH, +) from .models import ( AssemblyInfo, AssemblyInfoRequest, @@ -19,6 +24,7 @@ from .models import ( ListTypesResponse, TypeInfo, ) +from .utils import find_ilspycmd_path logger = logging.getLogger(__name__) @@ -28,7 +34,27 @@ MAX_OUTPUT_BYTES = 50_000_000 # 50 MB class ILSpyWrapper: - """Wrapper class for ILSpy command line tool.""" + """Wrapper class for ILSpy command line tool. + + This class encapsulates all interactions with the ilspycmd CLI tool. + While the wrapper is stateless in terms of decompilation operations + (each call is independent), it exists as a class to: + + 1. Cache the ilspycmd path lookup - Finding the executable involves + checking PATH and several common installation locations, which + is relatively expensive. Caching this on instantiation avoids + repeated filesystem operations. + + 2. Provide a single point of configuration - If ilspycmd is not found, + we fail fast at wrapper creation rather than on each tool call. + + 3. Enable future extensions - The class structure allows adding + connection pooling, result caching, or other optimizations without + changing the API. + + The wrapper should be created once and reused across tool calls. + See get_wrapper() in server.py for the recommended usage pattern. + """ def __init__(self, ilspycmd_path: str | None = None) -> None: """Initialize the wrapper. @@ -36,48 +62,12 @@ class ILSpyWrapper: Args: ilspycmd_path: Path to ilspycmd executable. If None, will try to find it in PATH. """ - self.ilspycmd_path = ilspycmd_path or self._find_ilspycmd() + self.ilspycmd_path = ilspycmd_path or find_ilspycmd_path() if not self.ilspycmd_path: raise RuntimeError( "ILSpyCmd not found. Please install it with: dotnet tool install --global ilspycmd" ) - def _find_ilspycmd(self) -> str | None: - """Find ilspycmd executable in PATH or common install locations. - - Checks: - 1. Standard PATH (via shutil.which) - 2. ~/.dotnet/tools (default location for 'dotnet tool install --global') - 3. Platform-specific locations - """ - # Try standard PATH first - for cmd_name in ["ilspycmd", "ilspycmd.exe"]: - path = shutil.which(cmd_name) - if path: - return path - - # Check common dotnet tools locations (not always in PATH) - home = os.path.expanduser("~") - dotnet_tools_paths = [ - os.path.join(home, ".dotnet", "tools", "ilspycmd"), - os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"), - ] - - # Windows-specific paths - if os.name == "nt": - userprofile = os.environ.get("USERPROFILE", "") - if userprofile: - dotnet_tools_paths.extend([ - os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe"), - ]) - - for tool_path in dotnet_tools_paths: - if os.path.isfile(tool_path) and os.access(tool_path, os.X_OK): - logger.info(f"Found ilspycmd at {tool_path} (not in PATH)") - return tool_path - - return None - async def _run_command( self, args: list[str], input_data: str | None = None ) -> tuple[int, str, str]: @@ -107,17 +97,18 @@ class ILSpyWrapper: input_bytes = input_data.encode("utf-8") if input_data else None - # Timeout after 5 minutes to prevent hanging on malicious/corrupted assemblies + # Timeout to prevent hanging on malicious/corrupted assemblies try: stdout_bytes, stderr_bytes = await asyncio.wait_for( process.communicate(input=input_bytes), - timeout=300.0 # 5 minutes + timeout=DECOMPILE_TIMEOUT_SECONDS, ) except asyncio.TimeoutError: - logger.warning("Command timed out after 5 minutes, killing process") + timeout_mins = DECOMPILE_TIMEOUT_SECONDS / 60 + logger.warning(f"Command timed out after {timeout_mins:.0f} minutes, killing process") process.kill() await process.wait() # Ensure process is cleaned up - return -1, "", "Command timed out after 5 minutes. The assembly may be corrupted or too complex." + return -1, "", f"Command timed out after {timeout_mins:.0f} minutes. The assembly may be corrupted or too complex." # Truncate output if it exceeds the limit to prevent memory exhaustion stdout_truncated = False @@ -369,10 +360,10 @@ class ILSpyWrapper: else: # Log unexpected lines (but don't fail - ilspycmd may output warnings/info) unparsed_count += 1 - if unparsed_count <= 3: # Avoid log spam - logger.debug(f"Skipping unparsed line from ilspycmd: {line[:100]}") + if unparsed_count <= MAX_UNPARSED_LOG_LINES: + logger.debug(f"Skipping unparsed line from ilspycmd: {line[:UNPARSED_LINE_PREVIEW_LENGTH]}") - if unparsed_count > 3: + if unparsed_count > MAX_UNPARSED_LOG_LINES: logger.debug(f"Skipped {unparsed_count} unparsed lines total") return types diff --git a/src/mcilspy/metadata_reader.py b/src/mcilspy/metadata_reader.py index c094bbf..fe330ea 100644 --- a/src/mcilspy/metadata_reader.py +++ b/src/mcilspy/metadata_reader.py @@ -4,107 +4,39 @@ Provides access to all 34+ CLR metadata tables without requiring ilspycmd. This enables searching for methods, fields, properties, events, and resources that are not exposed via the ilspycmd CLI. +This module contains CPU-bound synchronous code for parsing .NET PE metadata. +For heavy workloads with many concurrent requests, consider running these +operations in a thread pool (e.g., asyncio.to_thread) to avoid blocking +the event loop. + Note: dnfile provides flag attributes as boolean properties (e.g., mdPublic, fdStatic) rather than traditional IntFlag enums, so we use those directly. """ import logging -from dataclasses import dataclass, field from pathlib import Path from typing import Any import dnfile from dnfile.mdtable import TypeDefRow +from .models import ( + AssemblyMetadata, + EventInfo, + FieldInfo, + MethodInfo, + PropertyInfo, + ResourceInfo, +) + logger = logging.getLogger(__name__) + # Maximum assembly file size to load (in megabytes) # Prevents memory exhaustion from extremely large or malicious assemblies MAX_ASSEMBLY_SIZE_MB = 500 -@dataclass -class MethodInfo: - """Information about a method in an assembly.""" - - name: str - full_name: str - declaring_type: str - namespace: str | None - return_type: str | None = None - is_public: bool = False - is_static: bool = False - is_virtual: bool = False - is_abstract: bool = False - parameters: list[str] = field(default_factory=list) - - -@dataclass -class FieldInfo: - """Information about a field in an assembly.""" - - name: str - full_name: str - declaring_type: str - namespace: str | None - field_type: str | None = None - is_public: bool = False - is_static: bool = False - is_literal: bool = False # Constant - default_value: str | None = None - - -@dataclass -class PropertyInfo: - """Information about a property in an assembly.""" - - name: str - full_name: str - declaring_type: str - namespace: str | None - property_type: str | None = None - has_getter: bool = False - has_setter: bool = False - - -@dataclass -class EventInfo: - """Information about an event in an assembly.""" - - name: str - full_name: str - declaring_type: str - namespace: str | None - event_type: str | None = None - - -@dataclass -class ResourceInfo: - """Information about an embedded resource.""" - - name: str - size: int - is_public: bool = True - - -@dataclass -class AssemblyMetadata: - """Complete assembly metadata from dnfile.""" - - name: str - version: str - culture: str | None = None - public_key_token: str | None = None - target_framework: str | None = None - type_count: int = 0 - method_count: int = 0 - field_count: int = 0 - property_count: int = 0 - event_count: int = 0 - resource_count: int = 0 - referenced_assemblies: list[str] = field(default_factory=list) - - class AssemblySizeError(ValueError): """Raised when an assembly exceeds the maximum allowed size.""" diff --git a/src/mcilspy/models.py b/src/mcilspy/models.py index 9af3e32..f6df449 100644 --- a/src/mcilspy/models.py +++ b/src/mcilspy/models.py @@ -161,3 +161,84 @@ class AssemblyInfo(BaseModel): runtime_version: str | None = None is_signed: bool = False has_debug_info: bool = False + + +# ============================================================================= +# Metadata Reader Models (dnfile-based direct metadata access) +# ============================================================================= + + +class MethodInfo(BaseModel): + """Information about a method in an assembly.""" + + name: str + full_name: str + declaring_type: str + namespace: str | None = None + return_type: str | None = None + is_public: bool = False + is_static: bool = False + is_virtual: bool = False + is_abstract: bool = False + parameters: list[str] = Field(default_factory=list) + + +class FieldInfo(BaseModel): + """Information about a field in an assembly.""" + + name: str + full_name: str + declaring_type: str + namespace: str | None = None + field_type: str | None = None + is_public: bool = False + is_static: bool = False + is_literal: bool = False # Constant + default_value: str | None = None + + +class PropertyInfo(BaseModel): + """Information about a property in an assembly.""" + + name: str + full_name: str + declaring_type: str + namespace: str | None = None + property_type: str | None = None + has_getter: bool = False + has_setter: bool = False + + +class EventInfo(BaseModel): + """Information about an event in an assembly.""" + + name: str + full_name: str + declaring_type: str + namespace: str | None = None + event_type: str | None = None + + +class ResourceInfo(BaseModel): + """Information about an embedded resource.""" + + name: str + size: int = 0 + is_public: bool = True + + +class AssemblyMetadata(BaseModel): + """Complete assembly metadata from dnfile.""" + + name: str + version: str + culture: str | None = None + public_key_token: str | None = None + target_framework: str | None = None + type_count: int = 0 + method_count: int = 0 + field_count: int = 0 + property_count: int = 0 + event_count: int = 0 + resource_count: int = 0 + referenced_assemblies: list[str] = Field(default_factory=list) diff --git a/src/mcilspy/server.py b/src/mcilspy/server.py index 4a95e50..61295d9 100644 --- a/src/mcilspy/server.py +++ b/src/mcilspy/server.py @@ -5,12 +5,19 @@ import platform import re import shutil from contextlib import asynccontextmanager -from dataclasses import dataclass from mcp.server.fastmcp import Context, FastMCP +from .constants import ( + ALL_ENTITY_TYPES, + DEFAULT_MAX_SEARCH_RESULTS, + MAX_ERROR_OUTPUT_CHARS, + MAX_LINE_LENGTH, + MAX_MATCHES_PER_TYPE, +) from .ilspy_wrapper import ILSpyWrapper from .models import EntityType, LanguageVersion +from .utils import find_ilspycmd_path # Setup logging log_level = os.getenv("LOGLEVEL", "INFO").upper() @@ -21,27 +28,21 @@ logging.basicConfig( logger = logging.getLogger(__name__) -@dataclass -class AppState: - """Application state shared across tools via lifespan context.""" - - wrapper: ILSpyWrapper | None = None +# Module-level cached wrapper instance (lazily initialized) +# This avoids complex lifespan context access and provides simple caching +_cached_wrapper: ILSpyWrapper | None = None @asynccontextmanager async def app_lifespan(server: FastMCP): - """Initialize application state on startup, cleanup on shutdown. - - The ILSpyWrapper is lazily initialized on first use to avoid - failing startup if ilspycmd isn't installed (dnfile-based tools - still work without it). - """ - state = AppState() + """Initialize application state on startup, cleanup on shutdown.""" + global _cached_wrapper logger.info("mcilspy server starting up") try: - yield {"app_state": state} + yield {} finally: logger.info("mcilspy server shutting down") + _cached_wrapper = None # Clear cache on shutdown # Create FastMCP server with lifespan @@ -49,12 +50,14 @@ mcp = FastMCP("mcilspy", lifespan=app_lifespan) def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper: - """Get ILSpy wrapper instance from context or create new one. + """Get ILSpy wrapper instance, creating one if needed. + + The wrapper is cached at module level for efficiency. It's lazily + initialized on first use to avoid failing startup if ilspycmd + isn't installed (dnfile-based tools still work without it). Args: - ctx: Optional FastMCP context. If provided, uses lifespan state - for caching. If None, creates a new instance (for backwards - compatibility with non-tool callers). + ctx: Optional FastMCP context (unused, kept for API compatibility) Returns: ILSpyWrapper instance @@ -62,22 +65,10 @@ def get_wrapper(ctx: Context | None = None) -> ILSpyWrapper: Raises: RuntimeError: If ilspycmd is not installed """ - if ctx is not None: - # Use lifespan state for caching (access via request_context) - try: - lifespan_ctx = ctx.request_context.lifespan_context - if lifespan_ctx is not None: - state: AppState = lifespan_ctx.get("app_state") - if state is not None: - if state.wrapper is None: - state.wrapper = ILSpyWrapper() - return state.wrapper - except (AttributeError, ValueError): - # Context not fully initialized, fall through to create new instance - pass - - # Fallback: create new instance (no caching) - return ILSpyWrapper() + global _cached_wrapper + if _cached_wrapper is None: + _cached_wrapper = ILSpyWrapper() + return _cached_wrapper def _format_error(error: Exception, context: str = "") -> str: @@ -147,31 +138,29 @@ def _validate_assembly_path(assembly_path: str) -> str: return resolved_path -def _find_ilspycmd_path() -> str | None: - """Find ilspycmd in PATH or common install locations.""" - # Check PATH first - path = shutil.which("ilspycmd") - if path: - return path +def _compile_search_pattern( + pattern: str, case_sensitive: bool, use_regex: bool +) -> tuple[re.Pattern | None, str | None]: + """Compile a search pattern into a regex, handling errors. - # Check common dotnet tools locations (often not in PATH for MCP servers) - home = os.path.expanduser("~") - candidates = [ - os.path.join(home, ".dotnet", "tools", "ilspycmd"), - os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"), - ] + Args: + pattern: The search pattern string + case_sensitive: Whether matching should be case-sensitive + use_regex: Whether to treat pattern as a regular expression - # Windows-specific - if os.name == "nt": - userprofile = os.environ.get("USERPROFILE", "") - if userprofile: - candidates.append(os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe")) + Returns: + Tuple of (compiled_pattern, error_message). If use_regex is False, + returns (None, None). If regex compilation fails, returns + (None, error_message). Otherwise returns (pattern, None). + """ + if not use_regex: + return None, None - for candidate in candidates: - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - - return None + try: + flags = 0 if case_sensitive else re.IGNORECASE + return re.compile(pattern, flags), None + except re.error as e: + return None, f"Invalid regex pattern: {e}" async def _check_dotnet_tools() -> dict: @@ -202,7 +191,7 @@ async def _check_dotnet_tools() -> dict: pass # Check if ilspycmd is available (check PATH and common locations) - ilspy_path = _find_ilspycmd_path() + ilspy_path = find_ilspycmd_path() if ilspy_path: result["ilspycmd_available"] = True result["ilspycmd_path"] = ilspy_path @@ -380,7 +369,7 @@ async def _try_install_dotnet_sdk(ctx: Context | None = None) -> tuple[bool, str return False, ( f"❌ Installation failed (exit code {proc.returncode}).\n\n" f"Command: `{' '.join(cmd_args)}`\n\n" - f"Output:\n```\n{output[-1000:]}\n```\n\n" + f"Output:\n```\n{output[-MAX_ERROR_OUTPUT_CHARS:]}\n```\n\n" "Try running the command manually with sudo if needed." ) @@ -628,11 +617,21 @@ async def decompile_assembly( # Use simplified request object (no complex pydantic validation needed) from .models import DecompileRequest + # Validate language version with helpful error message + try: + lang_ver = LanguageVersion(language_version) + except ValueError: + valid_versions = [v.value for v in LanguageVersion] + return ( + f"Invalid language version: '{language_version}'\n\n" + f"Valid options: {', '.join(valid_versions)}" + ) + request = DecompileRequest( assembly_path=validated_path, output_dir=output_dir, type_name=type_name, - language_version=LanguageVersion(language_version), + language_version=lang_ver, create_project=create_project, show_il_code=show_il_code or show_il_sequence_points, remove_dead_code=remove_dead_code, @@ -906,7 +905,7 @@ async def search_types( # Default to search all entity types if entity_types is None: - entity_types = ["class", "interface", "struct", "delegate", "enum"] + entity_types = ALL_ENTITY_TYPES.copy() # Convert to EntityType enums entity_type_enums = [] @@ -926,14 +925,9 @@ async def search_types( return response.error_message or "Failed to list types" # Compile regex if needed - if use_regex: - try: - flags = 0 if case_sensitive else re.IGNORECASE - search_pattern = re.compile(pattern, flags) - except re.error as e: - return f"Invalid regex pattern: {e}" - else: - search_pattern = None + search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex) + if regex_error: + return regex_error # Filter types by pattern and namespace matching_types = [] @@ -996,7 +990,7 @@ async def search_strings( pattern: str, case_sensitive: bool = False, use_regex: bool = False, - max_results: int = 100, + max_results: int = DEFAULT_MAX_SEARCH_RESULTS, ctx: Context | None = None, ) -> str: """Search for string literals in assembly code. @@ -1046,12 +1040,9 @@ async def search_strings( source_code = response.source_code or "" # Compile regex if needed - if use_regex: - try: - flags = 0 if case_sensitive else re.IGNORECASE - search_pattern = re.compile(pattern, flags) - except re.error as e: - return f"Invalid regex pattern: {e}" + search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex) + if regex_error: + return regex_error # Search for string literals containing the pattern # In IL, strings appear as: ldstr "string value" @@ -1090,7 +1081,7 @@ async def search_strings( matches.append( { "line_num": i + 1, - "line": line.strip()[:200], # Truncate long lines + "line": line.strip()[:MAX_LINE_LENGTH], # Truncate long lines "type": current_type or "Unknown", "method": current_method, } @@ -1116,12 +1107,12 @@ async def search_strings( for type_name, type_matches in sorted(by_type.items()): content += f"## {type_name}\n\n" - for match in type_matches[:20]: # Limit per type + for match in type_matches[:MAX_MATCHES_PER_TYPE]: method_info = f" in `{match['method']}()`" if match["method"] else "" content += f"- Line {match['line_num']}{method_info}:\n" content += f" ```\n {match['line']}\n ```\n" - if len(type_matches) > 20: - content += f" ... and {len(type_matches) - 20} more matches in this type\n" + if len(type_matches) > MAX_MATCHES_PER_TYPE: + content += f" ... and {len(type_matches) - MAX_MATCHES_PER_TYPE} more matches in this type\n" content += "\n" content += "\n**TIP**: Use `decompile_assembly` with `type_name` to see the full context of interesting matches." @@ -1191,14 +1182,9 @@ async def search_methods( return "No methods found in assembly (or assembly has no metadata)" # Compile regex if needed - if use_regex: - try: - flags = 0 if case_sensitive else re.IGNORECASE - search_pattern = re.compile(pattern, flags) - except re.error as e: - return f"Invalid regex pattern: {e}" - else: - search_pattern = None + search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex) + if regex_error: + return regex_error # Filter by pattern matching_methods = [] @@ -1315,14 +1301,9 @@ async def search_fields( return "No fields found in assembly" # Compile regex if needed - if use_regex: - try: - flags = 0 if case_sensitive else re.IGNORECASE - search_pattern = re.compile(pattern, flags) - except re.error as e: - return f"Invalid regex pattern: {e}" - else: - search_pattern = None + search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex) + if regex_error: + return regex_error # Filter by pattern matching_fields = [] @@ -1428,14 +1409,9 @@ async def search_properties( return "No properties found in assembly" # Compile regex if needed - if use_regex: - try: - flags = 0 if case_sensitive else re.IGNORECASE - search_pattern = re.compile(pattern, flags) - except re.error as e: - return f"Invalid regex pattern: {e}" - else: - search_pattern = None + search_pattern, regex_error = _compile_search_pattern(pattern, case_sensitive, use_regex) + if regex_error: + return regex_error # Filter by pattern matching_props = [] diff --git a/src/mcilspy/utils.py b/src/mcilspy/utils.py new file mode 100644 index 0000000..6af08ce --- /dev/null +++ b/src/mcilspy/utils.py @@ -0,0 +1,50 @@ +"""Shared utility functions for mcilspy. + +This module contains common utilities used across the codebase to avoid +code duplication and ensure consistent behavior. +""" + +import logging +import os +import shutil + +logger = logging.getLogger(__name__) + + +def find_ilspycmd_path() -> str | None: + """Find ilspycmd executable in PATH or common install locations. + + This is the single source of truth for locating the ilspycmd binary. + It checks: + 1. Standard PATH (via shutil.which) + 2. ~/.dotnet/tools (default location for 'dotnet tool install --global') + 3. Platform-specific locations (Windows %USERPROFILE%) + + Returns: + Path to ilspycmd executable if found, None otherwise + """ + # Check PATH first (handles both ilspycmd and ilspycmd.exe) + for cmd_name in ["ilspycmd", "ilspycmd.exe"]: + path = shutil.which(cmd_name) + if path: + return path + + # Check common dotnet tools locations (not always in PATH for MCP servers) + home = os.path.expanduser("~") + candidates = [ + os.path.join(home, ".dotnet", "tools", "ilspycmd"), + os.path.join(home, ".dotnet", "tools", "ilspycmd.exe"), + ] + + # Windows-specific: also check USERPROFILE if different from ~ + if os.name == "nt": + userprofile = os.environ.get("USERPROFILE", "") + if userprofile and userprofile != home: + candidates.append(os.path.join(userprofile, ".dotnet", "tools", "ilspycmd.exe")) + + for candidate in candidates: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + logger.info(f"Found ilspycmd at {candidate} (not in PATH)") + return candidate + + return None