Skip to content

Commit f87a981

Browse files
richlanderCopilot
andauthored
Fix package member resolution (#307) (#308)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6f99865 commit f87a981

7 files changed

Lines changed: 263 additions & 65 deletions

File tree

skills/dotnet-inspect/SKILL.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: dotnet-inspect
3-
version: 0.7.5
3+
version: 0.7.6
44
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.
55
---
66

@@ -75,6 +75,30 @@ dnx dotnet-inspect -y -- diff --package System.CommandLine@2.0.0-beta4.22272.1..
7575
dnx dotnet-inspect -y -- member Command --package System.CommandLine@2.0.3 # new API surface
7676
```
7777

78+
### Find -> member workflow
79+
80+
Use `find` to discover the type, then carry forward the exact source information into `member`.
81+
82+
```bash
83+
dnx dotnet-inspect -y -- find RegexOptions \
84+
--package Microsoft.NETCore.App.Ref@11.0.0-preview.3.26179.102 --oneline
85+
```
86+
87+
The result row tells you:
88+
89+
- the owning **library** (for example `System.Text.RegularExpressions`)
90+
- the resolved **package version** to keep pinned in follow-up commands
91+
92+
Then inspect the type with the same `package@version`, adding `--library` for multi-library packages:
93+
94+
```bash
95+
dnx dotnet-inspect -y -- member RegexOptions \
96+
--package Microsoft.NETCore.App.Ref@11.0.0-preview.3.26179.102 \
97+
--library System.Text.RegularExpressions
98+
```
99+
100+
For framework libraries (`System.*`, `Microsoft.AspNetCore.*`), prefer `--platform <LibraryName>` when possible. Use `--package` when you specifically need a NuGet package or custom-feed workflow.
101+
78102
## Platform Diffs & Release Notes
79103

80104
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
87111

88112
**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.
89113

90-
**Nightly/preview packages from custom feeds:** The `--source` flag works for version listing but not package downloads. Pre-populate the NuGet cache instead:
114+
**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:
91115

92116
```bash
93-
# Pre-populate cache (fails with NU1213 but downloads the package)
94-
dotnet add package Microsoft.NETCore.App.Ref --version <version> --source <feed-url>
95-
# Then use normally — resolves from NuGet cache
96-
dnx dotnet-inspect -y -- diff --platform System.Runtime@P2..P3 --additive
117+
FEED="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet11/nuget/v3/index.json"
118+
VER="11.0.0-preview.3.26179.102"
119+
120+
dnx dotnet-inspect -y -- find RegexOptions \
121+
--package Microsoft.NETCore.App.Ref@${VER} --source "$FEED" --oneline
122+
123+
dnx dotnet-inspect -y -- member RegexOptions \
124+
--package Microsoft.NETCore.App.Ref@${VER} --source "$FEED" \
125+
--library System.Text.RegularExpressions
97126
```
98127

99128
## Version Resolution (Docker-style)

src/DotnetInspector.Services/TfmSelector.cs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ namespace DotnetInspector.Services;
88
/// </summary>
99
public static class TfmSelector
1010
{
11+
private static List<string> FilterResourceAssemblies(IEnumerable<string> dlls)
12+
=> dlls.Where(d => !d.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)).ToList();
13+
1114
public static List<string> GetPackageDlls(string extractPath)
1215
{
1316
var toolsDir = Path.Combine(extractPath, "tools");
@@ -41,7 +44,7 @@ public static List<string> GetPackageDlls(string extractPath)
4144

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

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

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

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

@@ -126,6 +129,103 @@ public static (List<string> paths, string? tfm) SelectHighestTfmAssemblies(List<
126129
return (byTfm[highestTfm], highestTfm);
127130
}
128131

132+
public static (string? path, string? tfm) FindAssemblyInPackage(string extractPath, string assemblyName, string? tfm = null)
133+
{
134+
var dlls = FilterResourceAssemblies(GetPackageDlls(extractPath));
135+
if (dlls.Count == 0)
136+
return (null, null);
137+
138+
var normalizedAssemblyName = assemblyName.Replace('\\', '/');
139+
var assemblyLeaf = Path.GetFileName(assemblyName);
140+
var bareName = assemblyLeaf.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
141+
? Path.GetFileNameWithoutExtension(assemblyLeaf)
142+
: assemblyLeaf;
143+
var fileName = assemblyLeaf.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
144+
? assemblyLeaf
145+
: $"{bareName}.dll";
146+
147+
var matchingFiles = dlls
148+
.Where(dll =>
149+
{
150+
var relativePath = Path.GetRelativePath(extractPath, dll).Replace('\\', '/');
151+
return relativePath.Equals(normalizedAssemblyName, StringComparison.OrdinalIgnoreCase)
152+
|| relativePath.Equals(normalizedAssemblyName + ".dll", StringComparison.OrdinalIgnoreCase)
153+
|| Path.GetFileName(dll).Equals(fileName, StringComparison.OrdinalIgnoreCase)
154+
|| Path.GetFileNameWithoutExtension(dll).Equals(bareName, StringComparison.OrdinalIgnoreCase);
155+
})
156+
.ToList();
157+
158+
if (matchingFiles.Count == 0)
159+
return (null, null);
160+
161+
if (!string.IsNullOrEmpty(tfm))
162+
{
163+
matchingFiles = matchingFiles
164+
.Where(dll => string.Equals(
165+
TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')),
166+
tfm,
167+
StringComparison.OrdinalIgnoreCase))
168+
.ToList();
169+
170+
if (matchingFiles.Count == 0)
171+
return (null, tfm);
172+
}
173+
174+
var (selectedPath, selectedTfm) = SelectHighestTfmAssembly(matchingFiles, extractPath);
175+
return (selectedPath ?? matchingFiles[0], selectedTfm ?? tfm);
176+
}
177+
178+
public static (string? path, string? tfm) FindAssemblyContainingType(string extractPath, string typeName, string? tfm = null)
179+
{
180+
var dlls = FilterResourceAssemblies(GetPackageDlls(extractPath));
181+
if (dlls.Count == 0)
182+
return (null, null);
183+
184+
string? selectedTfm = tfm;
185+
var candidateDlls = new List<string>();
186+
187+
if (!string.IsNullOrEmpty(tfm))
188+
{
189+
candidateDlls = dlls
190+
.Where(dll => string.Equals(
191+
TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/')),
192+
tfm,
193+
StringComparison.OrdinalIgnoreCase))
194+
.ToList();
195+
}
196+
else
197+
{
198+
var (highestTfmDlls, highestTfm) = SelectHighestTfmAssemblies(dlls, extractPath);
199+
if (highestTfmDlls.Count > 0)
200+
{
201+
candidateDlls = highestTfmDlls;
202+
selectedTfm = highestTfm;
203+
}
204+
}
205+
206+
foreach (var dll in candidateDlls)
207+
{
208+
if (PlatformResolver.HasType(dll, typeName))
209+
{
210+
selectedTfm ??= TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/'));
211+
return (dll, selectedTfm);
212+
}
213+
}
214+
215+
// Fallback: if the highest-TFM scan misses, search the remaining DLLs so
216+
// `find` results from multi-library packages still lead to a working follow-up.
217+
foreach (var dll in dlls.Except(candidateDlls))
218+
{
219+
if (PlatformResolver.HasType(dll, typeName))
220+
{
221+
var matchedTfm = TfmResolver.ExtractTfmFromPath(Path.GetRelativePath(extractPath, dll).Replace('\\', '/'));
222+
return (dll, matchedTfm ?? selectedTfm);
223+
}
224+
}
225+
226+
return (null, selectedTfm);
227+
}
228+
129229
public static string? FindAssemblyByTfm(string extractPath, string tfm, string? packageName = null)
130230
{
131231
var refDir = Path.Combine(extractPath, "ref");

src/dotnet-inspect.Tests/CommandExecutionTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
using System.IO.Compression;
12
using System.Text.Json;
23
using DotnetInspector.Commands;
34
using DotnetInspector.Options;
45
using DotnetInspector.Packages;
6+
using DotnetInspector.Services;
57

68
namespace DotnetInspector.Tests;
79

@@ -15,6 +17,28 @@ public class CommandExecutionTests
1517
private static readonly string TestAssemblyPath =
1618
typeof(CommandExecutionTests).Assembly.Location;
1719

20+
private static (string PackagePath, string TempDir) CreateLocalRefPackage(params string[] assemblyNames)
21+
{
22+
var tempDir = Path.Combine(Path.GetTempPath(), $"package-test-{Guid.NewGuid():N}");
23+
var packageRoot = Path.Combine(tempDir, "content");
24+
string? tfm = null;
25+
26+
foreach (var assemblyName in assemblyNames)
27+
{
28+
var (path, _, _, error) = PlatformResolver.ResolveAssembly(assemblyName);
29+
Assert.True(error == null && path != null, $"Could not resolve platform assembly '{assemblyName}': {error}");
30+
31+
tfm ??= Path.GetFileName(Path.GetDirectoryName(path!));
32+
var targetDir = Path.Combine(packageRoot, "ref", tfm!);
33+
Directory.CreateDirectory(targetDir);
34+
File.Copy(path!, Path.Combine(targetDir, Path.GetFileName(path!)));
35+
}
36+
37+
var packagePath = Path.Combine(tempDir, "Test.MultiLib.1.0.0.nupkg");
38+
ZipFile.CreateFromDirectory(packageRoot, packagePath);
39+
return (packagePath, tempDir);
40+
}
41+
1842
public CommandExecutionTests()
1943
{
2044
NuGetCache.Initialize("dotnet-inspect");
@@ -132,6 +156,58 @@ public async Task Find_PlatformLibrary_FindsType()
132156
Assert.Contains("JsonSerializer", output);
133157
}
134158

159+
[Fact]
160+
public async Task Member_PackageLibrarySelector_ResolvesBareLibraryName()
161+
{
162+
var (packagePath, tempDir) = CreateLocalRefPackage("System.Runtime", "System.Text.RegularExpressions");
163+
try
164+
{
165+
var options = new MemberOptions
166+
{
167+
TypeName = "RegexOptions",
168+
PackagePath = packagePath,
169+
AssemblyPath = "System.Text.RegularExpressions"
170+
};
171+
172+
var (exit, output, _) = await ConsoleCapture.RunAsync(
173+
() => MemberCommand.ExecuteAsync(options));
174+
175+
Assert.Equal(0, exit);
176+
Assert.Contains("RegexOptions", output);
177+
Assert.Contains("None", output);
178+
}
179+
finally
180+
{
181+
Directory.Delete(tempDir, recursive: true);
182+
}
183+
}
184+
185+
[Fact]
186+
public async Task Member_PackageTypeResolution_SearchesAcrossPackageLibraries()
187+
{
188+
var (packagePath, tempDir) = CreateLocalRefPackage("System.Runtime", "System.Text.RegularExpressions");
189+
try
190+
{
191+
var options = new MemberOptions
192+
{
193+
TypeName = "RegexOptions",
194+
PackagePath = packagePath,
195+
Verbosity = Verbosity.Minimal
196+
};
197+
198+
var (exit, output, _) = await ConsoleCapture.RunAsync(
199+
() => MemberCommand.ExecuteAsync(options));
200+
201+
Assert.Equal(0, exit);
202+
Assert.Contains("RegexOptions", output);
203+
Assert.Contains("Compiled", output);
204+
}
205+
finally
206+
{
207+
Directory.Delete(tempDir, recursive: true);
208+
}
209+
}
210+
135211
[Fact]
136212
public async Task Find_NoPattern_ShowsError()
137213
{

src/dotnet-inspect/CommandLine/Parsers/FindOptionsParser.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,26 @@ public static async Task<FindParseResult> ParseAsync(
115115
public static List<Tip> BuildTips(FindOptions options, string? pattern)
116116
{
117117
var pkg = options.Packages.Length > 0 ? options.Packages[0] : null;
118-
var sourceFlag = pkg != null ? $"--package {pkg}" : "--platform";
118+
if (pkg != null)
119+
{
120+
var sourceFlag = $"--package {pkg}";
121+
var pinnedSourceFlag = pkg.Contains("@", StringComparison.Ordinal)
122+
? sourceFlag
123+
: $"--package {pkg}@<version>";
124+
125+
return
126+
[
127+
new(MemberCommand.Name, $"<TypeName> {pinnedSourceFlag} --library <LibraryName>", "inspect the type you found"),
128+
new(FindCommand.Name, $"{pattern} {sourceFlag} --oneline", "compact output"),
129+
new(FindCommand.Name, $"{pattern} {sourceFlag} -v:d", "detailed results")
130+
];
131+
}
119132

120133
return
121134
[
122-
new(MemberCommand.Name, $"<TypeName> {sourceFlag}", "inspect type members"),
123-
new(FindCommand.Name, $"{pattern} {sourceFlag} --oneline", "compact output"),
124-
new(FindCommand.Name, $"{pattern} {sourceFlag} -v:d", "detailed results")
135+
new(MemberCommand.Name, "<TypeName> --platform <LibraryName>", "inspect the type you found"),
136+
new(FindCommand.Name, $"{pattern} --platform --oneline", "compact output"),
137+
new(FindCommand.Name, $"{pattern} --platform -v:d", "detailed results")
125138
];
126139
}
127140
}

src/dotnet-inspect/Commands/ApiCommand.cs

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ internal record SourceResult(
113113
var context = new CommandContext(options.Verbose);
114114
var logger = context.Logger;
115115
string? tempDir = null;
116+
string? selectedTfm = null;
116117

117118
string searchPath;
118119
string? runtimeAssemblyPath = null;
@@ -135,32 +136,42 @@ internal record SourceResult(
135136
apiSource = SourceKind.NuGet;
136137
apiVersion = packageVersion;
137138

138-
if (!string.IsNullOrEmpty(options.Tfm))
139+
if (!string.IsNullOrEmpty(options.AssemblyPath))
139140
{
140-
var tfmAssembly = TfmSelector.FindAssemblyByTfm(searchPath, options.Tfm, packageName);
141-
if (tfmAssembly == null)
141+
var (matchedAssembly, matchedTfm) = TfmSelector.FindAssemblyInPackage(searchPath, options.AssemblyPath, options.Tfm);
142+
if (matchedAssembly == null)
142143
{
143-
Console.Error.WriteLine($"Error: No library found for TFM '{options.Tfm}'.");
144+
Console.Error.WriteLine($"Error: Library '{options.AssemblyPath}' not found in package.");
144145
return (null!, 1);
145146
}
146-
searchPath = tfmAssembly;
147-
logger.Log($"Using TFM: {options.Tfm}");
147+
searchPath = matchedAssembly;
148+
selectedTfm = matchedTfm;
149+
if (selectedTfm != null)
150+
{
151+
logger.Log($"Using TFM: {selectedTfm}");
152+
}
148153
}
149-
else if (!string.IsNullOrEmpty(options.AssemblyPath))
154+
else if (!string.IsNullOrEmpty(typeName))
150155
{
151-
var targetPath = Path.Combine(searchPath, options.AssemblyPath.Replace('\\', '/'));
152-
// If it's a bare filename, search for it within the package
153-
if (!File.Exists(targetPath) && !options.AssemblyPath.Contains('/') && !options.AssemblyPath.Contains('\\'))
156+
var (typeAssembly, matchedTfm) = TfmSelector.FindAssemblyContainingType(searchPath, typeName, options.Tfm);
157+
if (typeAssembly != null)
154158
{
155-
var found = Directory.EnumerateFiles(searchPath, options.AssemblyPath, SearchOption.AllDirectories).FirstOrDefault();
156-
if (found != null) targetPath = found;
159+
searchPath = typeAssembly;
160+
selectedTfm = matchedTfm;
161+
logger.Log($"Resolved type '{typeName}' to {Path.GetFileName(searchPath)}");
157162
}
158-
if (!File.Exists(targetPath))
163+
}
164+
else if (!string.IsNullOrEmpty(options.Tfm))
165+
{
166+
var tfmAssembly = TfmSelector.FindAssemblyByTfm(searchPath, options.Tfm, packageName);
167+
if (tfmAssembly == null)
159168
{
160-
Console.Error.WriteLine($"Error: Library '{options.AssemblyPath}' not found in package.");
169+
Console.Error.WriteLine($"Error: No library found for TFM '{options.Tfm}'.");
161170
return (null!, 1);
162171
}
163-
searchPath = targetPath;
172+
searchPath = tfmAssembly;
173+
selectedTfm = options.Tfm;
174+
logger.Log($"Using TFM: {options.Tfm}");
164175
}
165176
}
166177
else if (!string.IsNullOrEmpty(options.AssemblyPath))
@@ -295,8 +306,6 @@ internal record SourceResult(
295306
return (null!, 1);
296307
}
297308

298-
string? selectedTfm = null;
299-
300309
// Derive TFM for platform assemblies from the version
301310
if (apiSource == SourceKind.Platform && apiVersion != null)
302311
{

0 commit comments

Comments
 (0)