Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions skills/dotnet-inspect/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
---

Expand Down Expand Up @@ -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 <LibraryName>` 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:
Expand All @@ -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 <feed-url>` 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 <version> --source <feed-url>
# 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)
Expand Down
104 changes: 102 additions & 2 deletions src/DotnetInspector.Services/TfmSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ namespace DotnetInspector.Services;
/// </summary>
public static class TfmSelector
{
private static List<string> FilterResourceAssemblies(IEnumerable<string> dlls)
=> dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList();

public static List<string> GetPackageDlls(string extractPath)
{
var toolsDir = Path.Combine(extractPath, "tools");
Expand Down Expand Up @@ -41,7 +44,7 @@ public static List<string> GetPackageDlls(string extractPath)

public static (string? path, string? tfm) SelectHighestTfmAssembly(List<string> dlls, string extractPath, string? packageName = null)
{
dlls = dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList();
dlls = FilterResourceAssemblies(dlls);

var byTfm = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);

Expand Down Expand Up @@ -96,7 +99,7 @@ public static (string? path, string? tfm) SelectHighestTfmAssembly(List<string>
/// </summary>
public static (List<string> paths, string? tfm) SelectHighestTfmAssemblies(List<string> dlls, string extractPath)
{
dlls = dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList();
dlls = FilterResourceAssemblies(dlls);

var byTfm = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);

Expand Down Expand Up @@ -126,6 +129,103 @@ public static (List<string> 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<string>();

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");
Expand Down
76 changes: 76 additions & 0 deletions src/dotnet-inspect.Tests/CommandExecutionTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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");
Expand Down Expand Up @@ -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()
{
Expand Down
21 changes: 17 additions & 4 deletions src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,26 @@ public static async Task<FindParseResult> ParseAsync(
public static List<Tip> 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}@<version>";

return
[
new(MemberCommand.Name, $"<TypeName> {pinnedSourceFlag} --library <LibraryName>", "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, $"<TypeName> {sourceFlag}", "inspect type members"),
new(FindCommand.Name, $"{pattern} {sourceFlag} --oneline", "compact output"),
new(FindCommand.Name, $"{pattern} {sourceFlag} -v:d", "detailed results")
new(MemberCommand.Name, "<TypeName> --platform <LibraryName>", "inspect the type you found"),
new(FindCommand.Name, $"{pattern} --platform --oneline", "compact output"),
new(FindCommand.Name, $"{pattern} --platform -v:d", "detailed results")
];
}
}
43 changes: 26 additions & 17 deletions src/dotnet-inspect/Commands/ApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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))
Expand Down Expand Up @@ -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)
{
Expand Down
Loading
Loading