diff --git a/skills/dotnet-inspect/SKILL.md b/skills/dotnet-inspect/SKILL.md index 283006e..5e2ff9c 100644 --- a/skills/dotnet-inspect/SKILL.md +++ b/skills/dotnet-inspect/SKILL.md @@ -1,6 +1,6 @@ --- name: dotnet-inspect -version: 0.7.5 +version: 0.7.6 description: Query .NET APIs across NuGet packages, platform libraries, and local files. Search for types, list API surfaces, compare and diff versions, find extension methods and implementors. Use whenever you need to answer questions about .NET library contents. --- @@ -75,6 +75,30 @@ dnx dotnet-inspect -y -- diff --package System.CommandLine@2.0.0-beta4.22272.1.. dnx dotnet-inspect -y -- member Command --package System.CommandLine@2.0.3 # new API surface ``` +### Find -> member workflow + +Use `find` to discover the type, then carry forward the exact source information into `member`. + +```bash +dnx dotnet-inspect -y -- find RegexOptions \ + --package Microsoft.NETCore.App.Ref@11.0.0-preview.3.26179.102 --oneline +``` + +The result row tells you: + +- the owning **library** (for example `System.Text.RegularExpressions`) +- the resolved **package version** to keep pinned in follow-up commands + +Then inspect the type with the same `package@version`, adding `--library` for multi-library packages: + +```bash +dnx dotnet-inspect -y -- member RegexOptions \ + --package Microsoft.NETCore.App.Ref@11.0.0-preview.3.26179.102 \ + --library System.Text.RegularExpressions +``` + +For framework libraries (`System.*`, `Microsoft.AspNetCore.*`), prefer `--platform ` when possible. Use `--package` when you specifically need a NuGet package or custom-feed workflow. + ## Platform Diffs & Release Notes For framework libraries (System.*, Microsoft.AspNetCore.*), use `--platform` instead of `--package`. This is the primary workflow for .NET release notes — diff each framework library between preview versions: @@ -87,13 +111,18 @@ dnx dotnet-inspect -y -- diff --platform System.Text.Json@9.0.0..10.0.0 **Multi-library packages:** `diff --package` works across all libraries in a package (e.g., `Microsoft.Azure.SignalR` with multiple DLLs). For framework ref packages like `Microsoft.NETCore.App.Ref`, prefer `--platform` per-library since it resolves from installed packs. -**Nightly/preview packages from custom feeds:** The `--source` flag works for version listing but not package downloads. Pre-populate the NuGet cache instead: +**Nightly/preview packages from custom feeds:** Use `--source ` directly with a pinned `package@version`, then carry the same source/version into follow-up commands: ```bash -# Pre-populate cache (fails with NU1213 but downloads the package) -dotnet add package Microsoft.NETCore.App.Ref --version --source -# Then use normally — resolves from NuGet cache -dnx dotnet-inspect -y -- diff --platform System.Runtime@P2..P3 --additive +FEED="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet11/nuget/v3/index.json" +VER="11.0.0-preview.3.26179.102" + +dnx dotnet-inspect -y -- find RegexOptions \ + --package Microsoft.NETCore.App.Ref@${VER} --source "$FEED" --oneline + +dnx dotnet-inspect -y -- member RegexOptions \ + --package Microsoft.NETCore.App.Ref@${VER} --source "$FEED" \ + --library System.Text.RegularExpressions ``` ## Version Resolution (Docker-style) diff --git a/src/DotnetInspector.Services/TfmSelector.cs b/src/DotnetInspector.Services/TfmSelector.cs index fa174c2..c1f2cb0 100644 --- a/src/DotnetInspector.Services/TfmSelector.cs +++ b/src/DotnetInspector.Services/TfmSelector.cs @@ -8,6 +8,9 @@ namespace DotnetInspector.Services; /// public static class TfmSelector { + private static List FilterResourceAssemblies(IEnumerable dlls) + => dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList(); + public static List GetPackageDlls(string extractPath) { var toolsDir = Path.Combine(extractPath, "tools"); @@ -41,7 +44,7 @@ public static List GetPackageDlls(string extractPath) public static (string? path, string? tfm) SelectHighestTfmAssembly(List dlls, string extractPath, string? packageName = null) { - dlls = dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList(); + dlls = FilterResourceAssemblies(dlls); var byTfm = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -96,7 +99,7 @@ public static (string? path, string? tfm) SelectHighestTfmAssembly(List /// public static (List paths, string? tfm) SelectHighestTfmAssemblies(List dlls, string extractPath) { - dlls = dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList(); + dlls = FilterResourceAssemblies(dlls); var byTfm = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -126,6 +129,103 @@ public static (List paths, string? tfm) SelectHighestTfmAssemblies(List< return (byTfm[highestTfm], highestTfm); } + public static (string? path, string? tfm) FindAssemblyInPackage(string extractPath, string assemblyName, string? tfm = null) + { + var dlls = FilterResourceAssemblies(GetPackageDlls(extractPath)); + if (dlls.Count == 0) + return (null, null); + + var normalizedAssemblyName = assemblyName.Replace('\\', '/'); + var assemblyLeaf = Path.GetFileName(assemblyName); + var bareName = assemblyLeaf.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? Path.GetFileNameWithoutExtension(assemblyLeaf) + : assemblyLeaf; + var fileName = assemblyLeaf.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? assemblyLeaf + : $"{bareName}.dll"; + + var matchingFiles = dlls + .Where(dll => + { + var relativePath = Path.GetRelativePath(extractPath, dll).Replace('\\', '/'); + return relativePath.Equals(normalizedAssemblyName, StringComparison.OrdinalIgnoreCase) + || relativePath.Equals(normalizedAssemblyName + ".dll", StringComparison.OrdinalIgnoreCase) + || Path.GetFileName(dll).Equals(fileName, StringComparison.OrdinalIgnoreCase) + || Path.GetFileNameWithoutExtension(dll).Equals(bareName, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + if (matchingFiles.Count == 0) + return (null, null); + + if (!string.IsNullOrEmpty(tfm)) + { + matchingFiles = matchingFiles + .Where(dll => string.Equals( + TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')), + tfm, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingFiles.Count == 0) + return (null, tfm); + } + + var (selectedPath, selectedTfm) = SelectHighestTfmAssembly(matchingFiles, extractPath); + return (selectedPath ?? matchingFiles[0], selectedTfm ?? tfm); + } + + public static (string? path, string? tfm) FindAssemblyContainingType(string extractPath, string typeName, string? tfm = null) + { + var dlls = FilterResourceAssemblies(GetPackageDlls(extractPath)); + if (dlls.Count == 0) + return (null, null); + + string? selectedTfm = tfm; + var candidateDlls = new List(); + + if (!string.IsNullOrEmpty(tfm)) + { + candidateDlls = dlls + .Where(dll => string.Equals( + TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')), + tfm, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + else + { + var (highestTfmDlls, highestTfm) = SelectHighestTfmAssemblies(dlls, extractPath); + if (highestTfmDlls.Count > 0) + { + candidateDlls = highestTfmDlls; + selectedTfm = highestTfm; + } + } + + foreach (var dll in candidateDlls) + { + if (PlatformResolver.HasType(dll, typeName)) + { + selectedTfm ??= TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')); + return (dll, selectedTfm); + } + } + + // Fallback: if the highest-TFM scan misses, search the remaining DLLs so + // `find` results from multi-library packages still lead to a working follow-up. + foreach (var dll in dlls.Except(candidateDlls)) + { + if (PlatformResolver.HasType(dll, typeName)) + { + var matchedTfm = TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')); + return (dll, matchedTfm ?? selectedTfm); + } + } + + return (null, selectedTfm); + } + public static string? FindAssemblyByTfm(string extractPath, string tfm, string? packageName = null) { var refDir = Path.Combine(extractPath, "ref"); diff --git a/src/dotnet-inspect.Tests/CommandExecutionTests.cs b/src/dotnet-inspect.Tests/CommandExecutionTests.cs index b47e09c..cc00d73 100644 --- a/src/dotnet-inspect.Tests/CommandExecutionTests.cs +++ b/src/dotnet-inspect.Tests/CommandExecutionTests.cs @@ -1,7 +1,9 @@ +using System.IO.Compression; using System.Text.Json; using DotnetInspector.Commands; using DotnetInspector.Options; using DotnetInspector.Packages; +using DotnetInspector.Services; namespace DotnetInspector.Tests; @@ -15,6 +17,28 @@ public class CommandExecutionTests private static readonly string TestAssemblyPath = typeof(CommandExecutionTests).Assembly.Location; + private static (string PackagePath, string TempDir) CreateLocalRefPackage(params string[] assemblyNames) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"package-test-{Guid.NewGuid():N}"); + var packageRoot = Path.Combine(tempDir, "content"); + string? tfm = null; + + foreach (var assemblyName in assemblyNames) + { + var (path, _, _, error) = PlatformResolver.ResolveAssembly(assemblyName); + Assert.True(error == null && path != null, $"Could not resolve platform assembly '{assemblyName}': {error}"); + + tfm ??= Path.GetFileName(Path.GetDirectoryName(path!)); + var targetDir = Path.Combine(packageRoot, "ref", tfm!); + Directory.CreateDirectory(targetDir); + File.Copy(path!, Path.Combine(targetDir, Path.GetFileName(path!))); + } + + var packagePath = Path.Combine(tempDir, "Test.MultiLib.1.0.0.nupkg"); + ZipFile.CreateFromDirectory(packageRoot, packagePath); + return (packagePath, tempDir); + } + public CommandExecutionTests() { NuGetCache.Initialize("dotnet-inspect"); @@ -132,6 +156,58 @@ public async Task Find_PlatformLibrary_FindsType() Assert.Contains("JsonSerializer", output); } + [Fact] + public async Task Member_PackageLibrarySelector_ResolvesBareLibraryName() + { + var (packagePath, tempDir) = CreateLocalRefPackage("System.Runtime", "System.Text.RegularExpressions"); + try + { + var options = new MemberOptions + { + TypeName = "RegexOptions", + PackagePath = packagePath, + AssemblyPath = "System.Text.RegularExpressions" + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => MemberCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("RegexOptions", output); + Assert.Contains("None", output); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task Member_PackageTypeResolution_SearchesAcrossPackageLibraries() + { + var (packagePath, tempDir) = CreateLocalRefPackage("System.Runtime", "System.Text.RegularExpressions"); + try + { + var options = new MemberOptions + { + TypeName = "RegexOptions", + PackagePath = packagePath, + Verbosity = Verbosity.Minimal + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => MemberCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("RegexOptions", output); + Assert.Contains("Compiled", output); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + [Fact] public async Task Find_NoPattern_ShowsError() { diff --git a/src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs b/src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs index aec6485..f766946 100644 --- a/src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs +++ b/src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs @@ -115,13 +115,26 @@ public static async Task ParseAsync( public static List BuildTips(FindOptions options, string? pattern) { var pkg = options.Packages.Length > 0 ? options.Packages[0] : null; - var sourceFlag = pkg != null ? $"--package {pkg}" : "--platform"; + if (pkg != null) + { + var sourceFlag = $"--package {pkg}"; + var pinnedSourceFlag = pkg.Contains("@", StringComparison.Ordinal) + ? sourceFlag + : $"--package {pkg}@"; + + return + [ + new(MemberCommand.Name, $" {pinnedSourceFlag} --library ", "inspect the type you found"), + new(FindCommand.Name, $"{pattern} {sourceFlag} --oneline", "compact output"), + new(FindCommand.Name, $"{pattern} {sourceFlag} -v:d", "detailed results") + ]; + } return [ - new(MemberCommand.Name, $" {sourceFlag}", "inspect type members"), - new(FindCommand.Name, $"{pattern} {sourceFlag} --oneline", "compact output"), - new(FindCommand.Name, $"{pattern} {sourceFlag} -v:d", "detailed results") + new(MemberCommand.Name, " --platform ", "inspect the type you found"), + new(FindCommand.Name, $"{pattern} --platform --oneline", "compact output"), + new(FindCommand.Name, $"{pattern} --platform -v:d", "detailed results") ]; } } diff --git a/src/dotnet-inspect/Commands/ApiCommand.cs b/src/dotnet-inspect/Commands/ApiCommand.cs index 10d5b80..6469380 100644 --- a/src/dotnet-inspect/Commands/ApiCommand.cs +++ b/src/dotnet-inspect/Commands/ApiCommand.cs @@ -113,6 +113,7 @@ internal record SourceResult( var context = new CommandContext(options.Verbose); var logger = context.Logger; string? tempDir = null; + string? selectedTfm = null; string searchPath; string? runtimeAssemblyPath = null; @@ -135,32 +136,42 @@ internal record SourceResult( apiSource = SourceKind.NuGet; apiVersion = packageVersion; - if (!string.IsNullOrEmpty(options.Tfm)) + if (!string.IsNullOrEmpty(options.AssemblyPath)) { - var tfmAssembly = TfmSelector.FindAssemblyByTfm(searchPath, options.Tfm, packageName); - if (tfmAssembly == null) + var (matchedAssembly, matchedTfm) = TfmSelector.FindAssemblyInPackage(searchPath, options.AssemblyPath, options.Tfm); + if (matchedAssembly == null) { - Console.Error.WriteLine($"Error: No library found for TFM '{options.Tfm}'."); + Console.Error.WriteLine($"Error: Library '{options.AssemblyPath}' not found in package."); return (null!, 1); } - searchPath = tfmAssembly; - logger.Log($"Using TFM: {options.Tfm}"); + searchPath = matchedAssembly; + selectedTfm = matchedTfm; + if (selectedTfm != null) + { + logger.Log($"Using TFM: {selectedTfm}"); + } } - else if (!string.IsNullOrEmpty(options.AssemblyPath)) + else if (!string.IsNullOrEmpty(typeName)) { - var targetPath = Path.Combine(searchPath, options.AssemblyPath.Replace('\\', '/')); - // If it's a bare filename, search for it within the package - if (!File.Exists(targetPath) && !options.AssemblyPath.Contains('/') && !options.AssemblyPath.Contains('\\')) + var (typeAssembly, matchedTfm) = TfmSelector.FindAssemblyContainingType(searchPath, typeName, options.Tfm); + if (typeAssembly != null) { - var found = Directory.EnumerateFiles(searchPath, options.AssemblyPath, SearchOption.AllDirectories).FirstOrDefault(); - if (found != null) targetPath = found; + searchPath = typeAssembly; + selectedTfm = matchedTfm; + logger.Log($"Resolved type '{typeName}' to {Path.GetFileName(searchPath)}"); } - if (!File.Exists(targetPath)) + } + else if (!string.IsNullOrEmpty(options.Tfm)) + { + var tfmAssembly = TfmSelector.FindAssemblyByTfm(searchPath, options.Tfm, packageName); + if (tfmAssembly == null) { - Console.Error.WriteLine($"Error: Library '{options.AssemblyPath}' not found in package."); + Console.Error.WriteLine($"Error: No library found for TFM '{options.Tfm}'."); return (null!, 1); } - searchPath = targetPath; + searchPath = tfmAssembly; + selectedTfm = options.Tfm; + logger.Log($"Using TFM: {options.Tfm}"); } } else if (!string.IsNullOrEmpty(options.AssemblyPath)) @@ -295,8 +306,6 @@ internal record SourceResult( return (null!, 1); } - string? selectedTfm = null; - // Derive TFM for platform assemblies from the version if (apiSource == SourceKind.Platform && apiVersion != null) { diff --git a/src/dotnet-inspect/Commands/AssemblyCommand.cs b/src/dotnet-inspect/Commands/AssemblyCommand.cs index aa7889b..064bdff 100644 --- a/src/dotnet-inspect/Commands/AssemblyCommand.cs +++ b/src/dotnet-inspect/Commands/AssemblyCommand.cs @@ -553,28 +553,8 @@ private static async Task> CollectPackageInspectionsAsyn return ([selectedPath], extractPath, tempDir, nupkgPath); } - // Normalize the assembly path for comparison - string normalizedAssemblyName = assemblyName.Replace('\\', '/'); - - // First try to match by relative path (for disambiguation) - string[] matchingFiles = allDlls - .Where(f => - { - string relativePath = Path.GetRelativePath(extractPath, f).Replace('\\', '/'); - return relativePath.Equals(normalizedAssemblyName, StringComparison.OrdinalIgnoreCase); - }) - .ToArray(); - - // If no exact path match, try matching by filename - if (matchingFiles.Length == 0) - { - matchingFiles = allDlls - .Where(f => Path.GetFileName(f).Equals(assemblyName, StringComparison.OrdinalIgnoreCase) || - Path.GetFileName(f).Equals(assemblyName + ".dll", StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - - if (matchingFiles.Length == 0) + var (matchedAssembly, matchedTfm) = TfmSelector.FindAssemblyInPackage(extractPath, assemblyName, tfm); + if (matchedAssembly == null) { Console.Error.WriteLine($"Error: Library '{assemblyName}' not found in package."); Console.Error.WriteLine("Use 'dotnet-inspect package --files' to list available libraries."); @@ -582,20 +562,11 @@ private static async Task> CollectPackageInspectionsAsyn return null; } - if (matchingFiles.Length > 1) - { - Console.Error.WriteLine($"Multiple matches found for '{assemblyName}':"); - foreach (var f in matchingFiles) - { - Console.Error.WriteLine($" {Path.GetRelativePath(extractPath, f)}"); - } - Console.Error.WriteLine("Specify the full relative path to disambiguate."); - if (tempDir != null) try { Directory.Delete(tempDir, recursive: true); } catch { } - return null; - } + if (matchedTfm != null) + logger.Log($"Using TFM: {matchedTfm}"); - logger.Log($"Found: {Path.GetRelativePath(extractPath, matchingFiles[0])}"); - return ([matchingFiles[0]], extractPath, tempDir, nupkgPath); + logger.Log($"Found: {Path.GetRelativePath(extractPath, matchedAssembly)}"); + return ([matchedAssembly], extractPath, tempDir, nupkgPath); } } diff --git a/src/dotnet-inspect/dotnet-inspect.csproj b/src/dotnet-inspect/dotnet-inspect.csproj index 0667ba4..48aeafb 100644 --- a/src/dotnet-inspect/dotnet-inspect.csproj +++ b/src/dotnet-inspect/dotnet-inspect.csproj @@ -21,7 +21,7 @@ Richard Lander true dotnet-inspect - 0.7.5 + 0.7.6 dotnet-inspect A CLI tool for inspecting .NET assemblies and NuGet packages MIT