From 5f84441df41d841179d51df7939f3cc911d20b2f Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 30 May 2026 10:26:04 -0700 Subject: [PATCH 1/5] Add async method detection (runtime vs state machine) to library command Adds an "Async Methods" section to `dotnet-inspect library` that lists public async methods and classifies each as: - Runtime: .NET 11 runtime async ("async v2"), detected via the MethodImplAttributes.Async flag (0x2000); no state machine. - State Machine: classic compiler async ("async v1"), detected via AsyncStateMachineAttribute / AsyncIteratorStateMachineAttribute. Mirrors the existing Unsafe/P-Invoke section pattern across the scanner, presence flags, model, service, view, and section pipeline, with unit tests (including a PersistedAssemblyBuilder fixture that emits the 0x2000 flag). Also enables `runtime-async=on` conditionally on net11.0 via a new root Directory.Build.targets (TargetFramework is not yet set when .props import, so the condition must live in .targets). This single root targets file also covers src/** projects, which do not inherit the root Directory.Build.props. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 1 - Directory.Build.targets | 11 ++ docs/architecture.md | 28 +++++ docs/design/section-pipeline.md | 4 +- docs/workflows/features.md | 1 + src/Directory.Build.props | 5 + .../AssemblyDetailScanner.cs | 47 +++++++- .../MethodClassificationScanner.cs | 43 +++++++- .../MethodClassificationScannerTests.cs | 103 ++++++++++++++++++ .../SectionPipelineTests.cs | 17 ++- .../Inspectors/LibraryMetadataService.cs | 22 ++++ .../Models/LibraryInspection.cs | 28 +++++ .../Sections/LibrarySections.cs | 11 ++ .../Views/LibraryInspectionView.cs | 14 +++ 14 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 Directory.Build.targets diff --git a/Directory.Build.props b/Directory.Build.props index ae6ba26e..ca5fda77 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,5 @@ true - $(Features);runtime-async=on diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..1b6face3 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,11 @@ + + + + + $(Features);runtime-async=on + + + diff --git a/docs/architecture.md b/docs/architecture.md index f61104d4..98584bdf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -238,6 +238,34 @@ for (int i = 0; i < ilBytes.Length - 1; i++) This approach is significantly more expensive (requires reading IL for every method) and would slow down API extraction. The current signature-based approach is a pragmatic choice that covers the most common use case: finding methods that expose pointers in their public API. +### Async Method Detection + +The **Async Methods** section lists public async methods and classifies each as one of two kinds: + +- **Runtime** — runtime async ("async v2"), introduced in .NET 11. The compiler emits the + method with the `MethodImplAttributes.Async` flag (`0x2000`) and no state machine; the + runtime drives the continuation. Enabled by compiling with `runtime-async=on` + on `net11.0`. All .NET 11 framework assemblies are compiled this way. +- **State Machine** — classic compiler-generated async ("async v1"). The compiler rewrites + the method into a state machine and marks it with `AsyncStateMachineAttribute` (or + `AsyncIteratorStateMachineAttribute` for `async` iterators). + +The two are mutually exclusive. Detection reads metadata directly — no IL scan required: + +```csharp +// Runtime async: method implementation flag 0x2000 +bool isRuntimeAsync = (method.ImplAttributes & (MethodImplAttributes)0x2000) != 0; + +// State-machine async: AsyncStateMachineAttribute / AsyncIteratorStateMachineAttribute +``` + +Like the Unsafe and P/Invoke sections, detection is **public-surface only** (skips +accessors and compiler-generated `<...>` types), so it surfaces the async API a caller sees. + +> Note: runtime async is a *compiler* opt-in. A method compiled with `runtime-async=on` +> emits the `0x2000` flag regardless of body shape (loops, `try`/`catch`/`finally`, +> `await using`, `await foreach`, `ConfigureAwait(false)` all classify as Runtime). + ### SourceLink Resolution SourceLink information is embedded in PDBs (portable or embedded) as custom debug information with GUID `CC110556-A091-4D38-9FEC-25AB9A351A6A`. diff --git a/docs/design/section-pipeline.md b/docs/design/section-pipeline.md index 521b58e6..ee909d2f 100644 --- a/docs/design/section-pipeline.md +++ b/docs/design/section-pipeline.md @@ -100,6 +100,7 @@ Multiple sections can share a scanner key. For example: | ------- | ----------- | | Unsafe Methods | `ClassifiedMethods` | | P/Invoke Methods | `ClassifiedMethods` | +| Async Methods | `ClassifiedMethods` | The `ClassifiedMethods` scanner runs once and populates both lists. `GetRequiredScanners` deduplicates keys, so requesting both sections does not scan twice. @@ -107,7 +108,7 @@ Sections with a `null` scanner key have their data collected unconditionally as ## Library Sections -The library command currently has 14 registered sections: +The library command currently has 15 registered sections: | Section | MinVerbosity | Scanner Key | | ------- | ------------ | ----------- | @@ -120,6 +121,7 @@ The library command currently has 14 registered sections: | Extension Methods | Detailed | `ExtensionMethods` | | Unsafe Methods | Detailed | `ClassifiedMethods` | | P/Invoke Methods | Detailed | `ClassifiedMethods` | +| Async Methods | Detailed | `ClassifiedMethods` | | Resources | Detailed | `Resources` | | Custom Attributes | Detailed | `CustomAttributes` | | Type Forwarders | Detailed | `TypeForwarders` | diff --git a/docs/workflows/features.md b/docs/workflows/features.md index 11bdaf32..c3a67f3a 100644 --- a/docs/workflows/features.md +++ b/docs/workflows/features.md @@ -111,6 +111,7 @@ | Extension Methods section | 7ee18e9 | 0.2.x | List extension methods in library | | Unsafe Methods section | 8ab7a7d | 0.2.x | List unsafe methods | | P/Invoke Methods section | 8ab7a7d | 0.2.x | List P/Invoke methods | +| Async Methods section | — | 0.3.x | List async methods, classified as runtime (net11) or state-machine async | | Type Forwarders section | 881706e | 0.2.x | Show type forwarding | | Resources section | 881706e | 0.2.x | List embedded resources | | `--extract-resources` | — | 0.3.x | Extract embedded resources to directory | diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c44874a0..f65d6f01 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,5 +1,10 @@ + + net10.0 true diff --git a/src/DotnetInspector.Metadata/AssemblyDetailScanner.cs b/src/DotnetInspector.Metadata/AssemblyDetailScanner.cs index b3c057b6..9ec1afbb 100644 --- a/src/DotnetInspector.Metadata/AssemblyDetailScanner.cs +++ b/src/DotnetInspector.Metadata/AssemblyDetailScanner.cs @@ -227,10 +227,11 @@ public static PresenceFlags ScanPresenceFlags(PEReader peReader) } } - // Extension types, P/Invoke, unsafe: iterate TypeDefs once + // Extension types, P/Invoke, unsafe, async: iterate TypeDefs once foreach (var typeDefHandle in reader.TypeDefinitions) { - if (flags.HasExtensionTypes && flags.HasPInvokeImports && flags.HasUnsafeCode) + if (flags.HasExtensionTypes && flags.HasPInvokeImports && flags.HasUnsafeCode + && flags.HasRuntimeAsync && flags.HasStateMachineAsync) break; var typeDef = reader.GetTypeDefinition(typeDefHandle); @@ -245,12 +246,18 @@ public static PresenceFlags ScanPresenceFlags(PEReader peReader) flags.HasExtensionTypes = true; } - // P/Invoke and unsafe: check methods - if (!flags.HasPInvokeImports || !flags.HasUnsafeCode) + // Async detection matches MethodClassificationScanner's public-surface filter: + // skip compiler-generated types (names starting with '<'). + bool considerAsync = (!flags.HasRuntimeAsync || !flags.HasStateMachineAsync) + && !reader.GetString(typeDef.Name).StartsWith('<'); + + // P/Invoke, unsafe, async: check methods + if (!flags.HasPInvokeImports || !flags.HasUnsafeCode || considerAsync) { foreach (var methodHandle in typeDef.GetMethods()) { - if (flags.HasPInvokeImports && flags.HasUnsafeCode) + if (flags.HasPInvokeImports && flags.HasUnsafeCode + && flags.HasRuntimeAsync && flags.HasStateMachineAsync) break; var method = reader.GetMethodDefinition(methodHandle); @@ -270,12 +277,36 @@ public static PresenceFlags ScanPresenceFlags(PEReader peReader) // Skip methods with undecodable signatures catch { } } + + if (considerAsync && (!flags.HasRuntimeAsync || !flags.HasStateMachineAsync) + && IsPublicNonAccessor(reader, method)) + { + const MethodImplAttributes AsyncImplFlag = (MethodImplAttributes)0x2000; + if (!flags.HasRuntimeAsync && (method.ImplAttributes & AsyncImplFlag) != 0) + flags.HasRuntimeAsync = true; + else if (!flags.HasStateMachineAsync + && (AttributeReader.HasAttribute(reader, method.GetCustomAttributes(), "System.Runtime.CompilerServices.AsyncStateMachineAttribute") + || AttributeReader.HasAttribute(reader, method.GetCustomAttributes(), "System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute"))) + flags.HasStateMachineAsync = true; + } } } } return flags; } + + private static bool IsPublicNonAccessor(MetadataReader reader, MethodDefinition method) + { + if ((method.Attributes & MethodAttributes.MemberAccessMask) != MethodAttributes.Public) + return false; + + string name = reader.GetString(method.Name); + return !name.StartsWith("get_", StringComparison.Ordinal) + && !name.StartsWith("set_", StringComparison.Ordinal) + && !name.StartsWith("add_", StringComparison.Ordinal) + && !name.StartsWith("remove_", StringComparison.Ordinal); + } } /// @@ -289,6 +320,12 @@ public class PresenceFlags public bool HasManifestResources { get; set; } public bool HasAssemblyAttributes { get; set; } public bool HasTypeForwarders { get; set; } + + /// Whether the assembly has any public runtime-async methods (impl flag 0x2000). + public bool HasRuntimeAsync { get; set; } + + /// Whether the assembly has any public classic state-machine async methods. + public bool HasStateMachineAsync { get; set; } } diff --git a/src/DotnetInspector.Metadata/MethodClassificationScanner.cs b/src/DotnetInspector.Metadata/MethodClassificationScanner.cs index 0dc6818e..1f41148e 100644 --- a/src/DotnetInspector.Metadata/MethodClassificationScanner.cs +++ b/src/DotnetInspector.Metadata/MethodClassificationScanner.cs @@ -21,7 +21,19 @@ public record ClassifiedMethodInfo( public enum MethodClassification { Unsafe, - PInvoke + PInvoke, + + /// + /// Runtime async method (.NET 11+): MethodImplAttributes.Async (0x2000) flag set, + /// suspension handled by the runtime with no compiler-generated state machine. + /// + RuntimeAsync, + + /// + /// Classic compiler state-machine async method: carries + /// AsyncStateMachineAttribute or AsyncIteratorStateMachineAttribute. + /// + StateMachineAsync } /// @@ -88,6 +100,15 @@ public static List Scan(PEReader peReader) continue; // P/Invoke methods are also "unsafe" but classify as P/Invoke } + // Check async (runtime async vs classic state-machine async) + var asyncClassification = ClassifyAsync(reader, method); + if (asyncClassification is { } asyncKind) + { + var signature = FormatSignature(reader, typeDef, method); + results.Add(new ClassifiedMethodInfo( + methodName, fullTypeName, ns, signature, asyncKind)); + } + // Check unsafe (pointer types in signature) try { @@ -111,6 +132,26 @@ public static List Scan(PEReader peReader) return results; } + /// + /// Classifies a method as runtime async or classic state-machine async, or null + /// if it is not an async method. Runtime async (.NET 11+) is identified by the + /// MethodImplAttributes.Async (0x2000) flag; classic async by the compiler-emitted + /// AsyncStateMachineAttribute / AsyncIteratorStateMachineAttribute. + /// + private static MethodClassification? ClassifyAsync(MetadataReader reader, MethodDefinition method) + { + const MethodImplAttributes AsyncImplFlag = (MethodImplAttributes)0x2000; + if ((method.ImplAttributes & AsyncImplFlag) != 0) + return MethodClassification.RuntimeAsync; + + var attributes = method.GetCustomAttributes(); + if (AttributeReader.HasAttribute(reader, attributes, "System.Runtime.CompilerServices.AsyncStateMachineAttribute") + || AttributeReader.HasAttribute(reader, attributes, "System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute")) + return MethodClassification.StateMachineAsync; + + return null; + } + private static bool HasPointerType(MethodSignature signature) { if (signature.ReturnType.Contains('*')) diff --git a/src/dotnet-inspect.Tests/MethodClassificationScannerTests.cs b/src/dotnet-inspect.Tests/MethodClassificationScannerTests.cs index 1064ee14..99393666 100644 --- a/src/dotnet-inspect.Tests/MethodClassificationScannerTests.cs +++ b/src/dotnet-inspect.Tests/MethodClassificationScannerTests.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using DotnetInspector.Metadata; using DotnetInspector.Services; @@ -50,6 +53,81 @@ public void Scan_DoesNotIncludeNormalMethods() Assert.DoesNotContain(results, m => m.MethodName == "SafeMethod"); } + [Fact] + public void Scan_ClassifiesRuntimeAsyncMethods() + { + // Synthesize an assembly with a method carrying MethodImplAttributes.Async + // (0x2000) so the runtime-async path is covered regardless of the SDK or the + // repo's runtime-async build setting. + using var stream = BuildAssemblyWithRuntimeAsyncMethod(); + + var results = MethodClassificationScanner.Scan(stream); + + var method = results.FirstOrDefault(m => m.MethodName == "FakeRuntimeAsync"); + Assert.NotNull(method); + Assert.Equal(MethodClassification.RuntimeAsync, method.Classification); + } + + [Fact] + public void Scan_ClassifiesStateMachineAsyncMethods() + { + // The classic-async path keys off AsyncStateMachineAttribute, applied directly + // so the test is deterministic regardless of the build's runtime-async setting. + var assemblyPath = typeof(MethodClassificationScannerTests).Assembly.Location; + using var stream = File.OpenRead(assemblyPath); + + var results = MethodClassificationScanner.Scan(stream); + + var method = results.FirstOrDefault(m => m.MethodName == "AttributedStateMachineAsync"); + Assert.NotNull(method); + Assert.Equal(MethodClassification.StateMachineAsync, method.Classification); + } + + [Fact] + public void Scan_ClassifiesRealAsyncMethodAsAsync() + { + // A real async method is detected as async; its kind depends on whether the + // assembly was compiled with runtime async. + var assemblyPath = typeof(MethodClassificationScannerTests).Assembly.Location; + using var stream = File.OpenRead(assemblyPath); + + var results = MethodClassificationScanner.Scan(stream); + + Assert.Contains(results, m => m.MethodName == "RealAsyncMethod" + && m.Classification is MethodClassification.RuntimeAsync + or MethodClassification.StateMachineAsync); + } + + [Fact] + public void Scan_DoesNotClassifyNonAsyncTaskMethods() + { + var assemblyPath = typeof(MethodClassificationScannerTests).Assembly.Location; + using var stream = File.OpenRead(assemblyPath); + + var results = MethodClassificationScanner.Scan(stream); + + Assert.DoesNotContain(results, m => m.MethodName == "NotAsyncTaskMethod"); + } + + private static MemoryStream BuildAssemblyWithRuntimeAsyncMethod() + { + const MethodImplAttributes AsyncImplFlag = (MethodImplAttributes)0x2000; + var ab = new PersistedAssemblyBuilder(new AssemblyName("RuntimeAsyncFixture"), typeof(object).Assembly); + var module = ab.DefineDynamicModule("RuntimeAsyncFixture"); + var type = module.DefineType("RuntimeAsyncSample", TypeAttributes.Public | TypeAttributes.Class); + var method = type.DefineMethod("FakeRuntimeAsync", MethodAttributes.Public, typeof(Task), Type.EmptyTypes); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Ldnull); + il.Emit(OpCodes.Ret); + method.SetImplementationFlags(AsyncImplFlag); + type.CreateType(); + + var stream = new MemoryStream(); + ab.Save(stream); + stream.Position = 0; + return stream; + } + [Fact] public void Scan_PlatformAssembly_FindsUnsafeMethods() { @@ -91,3 +169,28 @@ public static partial class SamplePInvokeClass [DllImport("kernel32.dll")] public static extern int GetCurrentProcessId(); } + +/// +/// Sample class with async methods for testing async classification. +/// +public class SampleAsyncClass +{ + public async Task RealAsyncMethod() + { + await Task.Yield(); + return 42; + } + + public Task NotAsyncTaskMethod() => Task.FromResult(1); +} + +/// +/// Sample class exercising the classic state-machine async detection path. The +/// attribute is applied directly so detection is deterministic even when the +/// build emits runtime async for real async methods. +/// +public class SampleStateMachineAsyncClass +{ + [AsyncStateMachine(typeof(SampleStateMachineAsyncClass))] + public Task AttributedStateMachineAsync() => Task.FromResult(1); +} diff --git a/src/dotnet-inspect.Tests/SectionPipelineTests.cs b/src/dotnet-inspect.Tests/SectionPipelineTests.cs index 2a47d977..c924b92e 100644 --- a/src/dotnet-inspect.Tests/SectionPipelineTests.cs +++ b/src/dotnet-inspect.Tests/SectionPipelineTests.cs @@ -207,7 +207,7 @@ public void LibraryPipeline_HasExpectedSectionCount() { var pipeline = LibrarySections.CreatePipeline(); - Assert.Equal(12, pipeline.AllSectionNames.Length); + Assert.Equal(13, pipeline.AllSectionNames.Length); } [Fact] @@ -445,6 +445,21 @@ public void CanRender_PInvokeMethods_UsesPresenceFlag() Assert.Contains("P/Invoke Methods", effective); } + [Fact] + public void CanRender_AsyncMethods_UsesPresenceFlag() + { + var pipeline = LibrarySections.CreatePipeline(); + var model = new LibraryInspection + { + AssemblyInfo = new AssemblyInfo(), + HasRuntimeAsync = true + }; + + var effective = pipeline.GetEffectiveSections(model, Verbosity.Detailed); + + Assert.Contains("Async Methods", effective); + } + [Fact] public void CanRender_Resources_UsesPresenceFlag() { diff --git a/src/dotnet-inspect/Inspectors/LibraryMetadataService.cs b/src/dotnet-inspect/Inspectors/LibraryMetadataService.cs index 21b7cb77..59ca51b2 100644 --- a/src/dotnet-inspect/Inspectors/LibraryMetadataService.cs +++ b/src/dotnet-inspect/Inspectors/LibraryMetadataService.cs @@ -71,6 +71,8 @@ internal static class LibraryMetadataService inspection.HasExtensionTypes = presenceFlags.HasExtensionTypes; inspection.HasPInvokeImports = presenceFlags.HasPInvokeImports; inspection.HasUnsafeCode = presenceFlags.HasUnsafeCode; + inspection.HasRuntimeAsync = presenceFlags.HasRuntimeAsync; + inspection.HasStateMachineAsync = presenceFlags.HasStateMachineAsync; inspection.HasManifestResources = presenceFlags.HasManifestResources; inspection.HasAssemblyAttributes = presenceFlags.HasAssemblyAttributes; inspection.HasExportedTypeForwarders = presenceFlags.HasTypeForwarders; @@ -413,6 +415,26 @@ internal static void ScanClassifiedMethods(string path, LibraryInspection inspec inspection.UnsafeMethods = unsafe_.Count > 0 ? unsafe_ : null; inspection.PInvokeMethods = pinvoke.Count > 0 ? pinvoke : null; + + var async = classified + .Where(m => m.Classification is MethodClassification.RuntimeAsync + or MethodClassification.StateMachineAsync) + .Select(m => new AsyncMethodSummary + { + MethodName = m.MethodName, + DeclaringType = m.DeclaringType, + Signature = m.Signature, + Kind = m.Classification == MethodClassification.RuntimeAsync + ? "Runtime" + : "State Machine" + }) + // Runtime async first (sorts before "State Machine"), then by type/name. + .OrderBy(m => m.Kind, StringComparer.Ordinal) + .ThenBy(m => m.DeclaringType) + .ThenBy(m => m.MethodName) + .ToList(); + + inspection.AsyncMethods = async.Count > 0 ? async : null; } catch (Exception ex) { diff --git a/src/dotnet-inspect/Models/LibraryInspection.cs b/src/dotnet-inspect/Models/LibraryInspection.cs index 0b161ca6..c2562062 100644 --- a/src/dotnet-inspect/Models/LibraryInspection.cs +++ b/src/dotnet-inspect/Models/LibraryInspection.cs @@ -160,6 +160,12 @@ public class LibraryInspection [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? PInvokeMethods { get; set; } + /// + /// Public async methods, classified as runtime async or classic state-machine async. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? AsyncMethods { get; set; } + /// /// Manifest resources embedded in this assembly. /// @@ -199,6 +205,14 @@ public class LibraryInspection [JsonIgnore] public bool HasUnsafeCode { get; set; } + /// Whether the assembly contains any public runtime-async methods (impl flag 0x2000). + [JsonIgnore] + public bool HasRuntimeAsync { get; set; } + + /// Whether the assembly contains any public classic state-machine async methods. + [JsonIgnore] + public bool HasStateMachineAsync { get; set; } + /// Whether the assembly has manifest resources. [JsonIgnore] public bool HasManifestResources { get; set; } @@ -242,6 +256,20 @@ public record class ClassifiedMethodSummary public string? ModuleName { get; init; } } +/// +/// Summary of an async method, including whether it is runtime async or classic +/// state-machine async. +/// +public record class AsyncMethodSummary +{ + public string MethodName { get; init; } = ""; + public string DeclaringType { get; init; } = ""; + public string Signature { get; init; } = ""; + + /// "Runtime" for runtime async, "State Machine" for classic compiler async. + public string Kind { get; init; } = ""; +} + /// /// Summary of a manifest resource in a library. /// diff --git a/src/dotnet-inspect/Sections/LibrarySections.cs b/src/dotnet-inspect/Sections/LibrarySections.cs index 2ddf0c9d..ee68d7cb 100644 --- a/src/dotnet-inspect/Sections/LibrarySections.cs +++ b/src/dotnet-inspect/Sections/LibrarySections.cs @@ -31,6 +31,7 @@ public static SectionPipeline CreatePipeline() .Add() .Add() .Add() + .Add() .Add() .Add() .Add() @@ -132,6 +133,16 @@ public static bool CanRender(LibraryInspection model) => model.PInvokeMethods is { Count: > 0 } || model.HasPInvokeImports; } + public sealed class AsyncMethods : ISectionDescriptor + { + public static string Name => "Async Methods"; + public static bool IsExpensive => false; + public static string? ScannerKey => ScannerClassifiedMethods; + public static bool CanRender(LibraryInspection model) + => model.AsyncMethods is { Count: > 0 } + || model.HasRuntimeAsync || model.HasStateMachineAsync; + } + public sealed class Resources : ISectionDescriptor { public static string Name => "Resources"; diff --git a/src/dotnet-inspect/Views/LibraryInspectionView.cs b/src/dotnet-inspect/Views/LibraryInspectionView.cs index 05f35efe..b9ea7607 100644 --- a/src/dotnet-inspect/Views/LibraryInspectionView.cs +++ b/src/dotnet-inspect/Views/LibraryInspectionView.cs @@ -166,6 +166,13 @@ public LibraryInspectionView(LibraryInspection data, bool topFieldsOnly = false) public List? PInvokeMethodsSection => _data.PInvokeMethods?.Select(m => new PInvokeMethodRow(m.MethodName, m.DeclaringType, m.ModuleName ?? "", m.Signature)).ToList(); + [MarkoutIgnore] + public bool HasAsyncMethods => _data.AsyncMethods is { Count: > 0 }; + + [MarkoutSection(Name = "Async Methods", ShowWhenProperty = nameof(HasAsyncMethods))] + public List? AsyncMethodsSection => + _data.AsyncMethods?.Select(m => new AsyncMethodRow(m.MethodName, m.DeclaringType, m.Kind, m.Signature)).ToList(); + [MarkoutIgnore] public bool HasResources => _data.Resources is { Count: > 0 }; @@ -312,6 +319,13 @@ public record PInvokeMethodRow( string Module, string Signature); +[MarkoutSerializable] +public record AsyncMethodRow( + string Name, + [property: MarkoutPropertyName("Declaring Type")] string DeclaringType, + string Kind, + string Signature); + [MarkoutSerializable] public record ResourceRow( string Name, From b24fd38847a71f7ccc7e1e5e03d59d7586019a04 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 30 May 2026 10:39:26 -0700 Subject: [PATCH 2/5] Fix platform assembly resolution picking wrong prerelease version ParseVersion stripped the prerelease suffix before parsing, so multiple side-by-side runtimes sharing a base version (e.g. 11.0.0-preview.3 and 11.0.0-preview.4) collapsed to the same System.Version. "Latest" selection then fell back to directory enumeration order, picking an older preview. Parse with NuGetVersion so prerelease labels order by SemVer precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotnetInspector.Services.csproj | 1 + .../PlatformResolver.cs | 28 +++------- .../PlatformResolverTests.cs | 55 +++++++++++++++++++ 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/DotnetInspector.Services/DotnetInspector.Services.csproj b/src/DotnetInspector.Services/DotnetInspector.Services.csproj index 6b367118..8a111a8d 100644 --- a/src/DotnetInspector.Services/DotnetInspector.Services.csproj +++ b/src/DotnetInspector.Services/DotnetInspector.Services.csproj @@ -32,6 +32,7 @@ + diff --git a/src/DotnetInspector.Services/PlatformResolver.cs b/src/DotnetInspector.Services/PlatformResolver.cs index 1e101454..1f33c4f7 100644 --- a/src/DotnetInspector.Services/PlatformResolver.cs +++ b/src/DotnetInspector.Services/PlatformResolver.cs @@ -1,5 +1,6 @@ using System.Reflection.Metadata; using System.Runtime.InteropServices; +using NuGet.Versioning; namespace DotnetInspector.Services; @@ -832,26 +833,15 @@ private static int CountAssemblies(string refPath) return null; } - private static Version ParseVersion(string versionString) + private static NuGetVersion ParseVersion(string versionString) { - // Handle versions like "9.0.12" and "10.0.0-preview.5.25277.114" - var dashIndex = versionString.IndexOf('-'); - var cleanVersion = dashIndex > 0 ? versionString[..dashIndex] : versionString; - - // Pad to at least 3 parts - var parts = cleanVersion.Split('.'); - while (parts.Length < 3) - { - cleanVersion += ".0"; - parts = cleanVersion.Split('.'); - } - - if (Version.TryParse(cleanVersion, out var version)) - { - return version; - } - - return new Version(0, 0, 0); + // Use full SemVer parsing so prerelease labels are ordered correctly. + // Stripping the prerelease suffix would collapse builds that share a + // base version (e.g. "11.0.0-preview.3.x" and "11.0.0-preview.4.x") + // into the same value, making "latest" selection arbitrary. + return NuGetVersion.TryParse(versionString, out var version) + ? version + : new NuGetVersion(0, 0, 0); } /// diff --git a/src/dotnet-inspect.Tests/PlatformResolverTests.cs b/src/dotnet-inspect.Tests/PlatformResolverTests.cs index e6e16f6d..b1ee86b1 100644 --- a/src/dotnet-inspect.Tests/PlatformResolverTests.cs +++ b/src/dotnet-inspect.Tests/PlatformResolverTests.cs @@ -66,6 +66,61 @@ public void GetInstalledVersions_SortsVersionsDescending() } } + [Fact] + public void GetInstalledVersions_OrdersPrereleasesOfSameBaseVersion() + { + // Regression: prerelease labels sharing a base version (e.g. multiple + // 11.0.0 previews installed side-by-side) must order by SemVer + // precedence, not collapse to the base version and fall back to + // directory enumeration order. + var tempDir = Path.Combine(Path.GetTempPath(), $"platform-test-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0-preview.1.26104.118")); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0-preview.3.26207.106")); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0-preview.4.26230.115")); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0-preview.2.26159.112")); + + var versions = PlatformResolver.GetInstalledVersions(tempDir); + + Assert.Equal(4, versions.Count); + Assert.Equal("11.0.0-preview.4.26230.115", versions[0]); + Assert.Equal("11.0.0-preview.3.26207.106", versions[1]); + Assert.Equal("11.0.0-preview.2.26159.112", versions[2]); + Assert.Equal("11.0.0-preview.1.26104.118", versions[3]); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void GetInstalledVersions_OrdersReleaseAbovePrereleaseOfSameBase() + { + // A stable release must sort above any prerelease sharing its base version. + var tempDir = Path.Combine(Path.GetTempPath(), $"platform-test-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0-preview.4.26230.115")); + Directory.CreateDirectory(Path.Combine(tempDir, "11.0.0")); + + var versions = PlatformResolver.GetInstalledVersions(tempDir); + + Assert.Equal(2, versions.Count); + Assert.Equal("11.0.0", versions[0]); + Assert.Equal("11.0.0-preview.4.26230.115", versions[1]); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + [Fact] public void GetInstalledVersions_IgnoresNonVersionDirectories() { From 829364a808e52f2ae785743a73f7efa6f631aa41 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 30 May 2026 10:51:27 -0700 Subject: [PATCH 3/5] Resolve runtime-only implementation assemblies (e.g. System.Private.CoreLib) ResolveAssembly only checked the shared runtime after finding an assembly in a ref pack. Runtime-only implementation assemblies (System.Private.CoreLib, System.Private.Uri, System.Private.Xml, ...) exist in the shared runtime but have no ref-pack counterpart, so they never resolved as Platform and fell through to a failing NuGet package lookup. Add a shared-runtime fallback to both the bare-name and explicit-framework resolution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PlatformResolver.cs | 26 +++++++++++++++---- .../PlatformResolverTests.cs | 24 +++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/DotnetInspector.Services/PlatformResolver.cs b/src/DotnetInspector.Services/PlatformResolver.cs index 1f33c4f7..edb27fa3 100644 --- a/src/DotnetInspector.Services/PlatformResolver.cs +++ b/src/DotnetInspector.Services/PlatformResolver.cs @@ -591,16 +591,23 @@ public static (string? AssemblyPath, string? Framework, string? Version, string? } var assemblyPath = FindAssemblyCaseInsensitive(refPath!, assemblyName); + + // Get framework short name + var frameworkName = frameworkSpec.Contains('@') + ? frameworkSpec[..frameworkSpec.LastIndexOf('@')] + : frameworkSpec; + if (assemblyPath == null) { + // Runtime-only implementation assemblies (e.g. System.Private.CoreLib) + // live in the shared runtime but have no ref-pack counterpart. + var rtOnly = ResolveRuntimeAssembly(assemblyName, frameworkSpec); + if (rtOnly.AssemblyPath != null) + return rtOnly; + return (null, null, null, $"Library '{assemblyName}' not found in {frameworkSpec}"); } - // Get framework short name - var frameworkName = frameworkSpec.Contains('@') - ? frameworkSpec[..frameworkSpec.LastIndexOf('@')] - : frameworkSpec; - // For unversioned specs, prefer runtime when it has an equal or newer version if (!frameworkSpec.Contains('@')) { @@ -650,6 +657,15 @@ public static (string? AssemblyPath, string? Framework, string? Version, string? return (assemblyPath, framework.ShortName, framework.LatestVersion, null); } + // Fallback: runtime-only implementation assemblies (e.g. System.Private.CoreLib) + // live in the shared runtime but have no ref-pack counterpart. + foreach (var shortName in new[] { "runtime", "aspnetcore" }) + { + var rt = ResolveRuntimeAssembly(assemblyName, shortName); + if (rt.AssemblyPath != null) + return rt; + } + return (null, null, null, $"Library '{assemblyName}' not found in any installed framework"); } diff --git a/src/dotnet-inspect.Tests/PlatformResolverTests.cs b/src/dotnet-inspect.Tests/PlatformResolverTests.cs index b1ee86b1..0bf15986 100644 --- a/src/dotnet-inspect.Tests/PlatformResolverTests.cs +++ b/src/dotnet-inspect.Tests/PlatformResolverTests.cs @@ -144,6 +144,30 @@ public void GetInstalledVersions_IgnoresNonVersionDirectories() } } + [Fact] + public void ResolveAssembly_RuntimeOnlyImplementationAssembly_ResolvesFromSharedRuntime() + { + // Runtime-only implementation assemblies (e.g. System.Private.CoreLib) exist + // in the shared runtime but have no ref-pack counterpart. They must still + // resolve (as Platform) rather than falling through to a NuGet lookup. + if (PlatformResolver.GetSharedDirectory() is not { } sharedDir + || !Directory.Exists(Path.Combine(sharedDir, "Microsoft.NETCore.App"))) + { + Assert.Skip("No shared Microsoft.NETCore.App runtime installed."); + return; + } + + var (assemblyPath, framework, version, error) = + PlatformResolver.ResolveAssembly("System.Private.CoreLib"); + + Assert.Null(error); + Assert.NotNull(assemblyPath); + Assert.EndsWith("System.Private.CoreLib.dll", assemblyPath); + Assert.Contains(Path.Combine("shared", "Microsoft.NETCore.App"), assemblyPath); + Assert.Equal("runtime", framework); + Assert.NotNull(version); + } + [Fact] public void ResolveFramework_UnknownFramework_ReturnsError() { From c17ab33d68462572715936b04c47857b9a354850 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 30 May 2026 11:06:00 -0700 Subject: [PATCH 4/5] Document async method detection and runtime-only assembly resolution in SKILL/README Cover the new 'Async Methods' library section (Runtime vs State Machine classification) and note that runtime-only platform assemblies such as System.Private.CoreLib now resolve via bare name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++- skills/dotnet-inspect/SKILL.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8da85431..50380126 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ dnx dotnet-inspect -y -- ### Bare Names -A bare name like `dotnet-inspect System.Text.Json` uses a router to pick the best source. Platform libraries (`System.*`, `Microsoft.AspNetCore`) resolve to the installed SDK by default. Other names resolve to NuGet packages. Use explicit `package` or `library --package` to override. +A bare name like `dotnet-inspect System.Text.Json` uses a router to pick the best source. Platform libraries (`System.*`, `Microsoft.AspNetCore`) resolve to the installed SDK by default — including runtime-only implementation assemblies such as `System.Private.CoreLib` that have no NuGet package. Other names resolve to NuGet packages. Use explicit `package` or `library --package` to override. ### Common Flags @@ -98,6 +98,7 @@ dotnet-inspect library --package System.Text.Json -s # List 13 available dotnet-inspect library --package System.Text.Json --source-link-audit # SourceLink audit dotnet-inspect library Microsoft.Extensions.AI.OpenAI --dependencies # Dependency tree (visual) dotnet-inspect library System.Text.Json --references -s Lib* # Direct references +dotnet-inspect library System.Net.Security -S "Async*" # Async methods (Runtime vs State Machine) dotnet-inspect library --package System.Text.Json --extract-resources resources/ # Extract resources ``` diff --git a/skills/dotnet-inspect/SKILL.md b/skills/dotnet-inspect/SKILL.md index 536fb034..72f2de5a 100644 --- a/skills/dotnet-inspect/SKILL.md +++ b/skills/dotnet-inspect/SKILL.md @@ -36,6 +36,7 @@ Query .NET library APIs — the same commands work across NuGet packages, platfo - **"This code uses an old API — fix it"** — `diff` the old..new version, then `member` to see the new API - **"What extends this type?"** — `extensions` finds extension methods/properties (`--reachable` for transitive) - **"What implements this interface?"** — `implements` finds concrete types +- **"Which methods are async — and runtime or state-machine?"** — `library X -S "Async*"` lists async methods, classifying each as `Runtime` (.NET 11 runtime async) or `State Machine` (classic compiler async) - **"What does this type depend on?"** — `depends` walks type hierarchy, package deps, or library refs - **"Show dependencies as a diagram"** — `depends --mermaid` for standalone mermaid, `--markdown --mermaid` for embedded - **"Where is the source code?"** — `source` returns SourceLink URLs; add member name for line numbers @@ -174,6 +175,7 @@ dnx dotnet-inspect -y -- System.Text.Json -D # list sec dnx dotnet-inspect -y -- System.Text.Json -D --effective # sections with data (dry run) dnx dotnet-inspect -y -- library System.Text.Json -D --tree # full schema tree dnx dotnet-inspect -y -- System.Text.Json -S Symbols # render one section +dnx dotnet-inspect -y -- System.Net.Security -S "Async*" # async methods (Runtime vs State Machine) dnx dotnet-inspect -y -- System.Text.Json -S Symbols --fields "PDB*" # project specific fields dnx dotnet-inspect -y -- type System.Text.Json --columns Kind,Type # project specific columns ``` From 3083719058c07c0ca736b51f0ec8f3a5c1b21ec2 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 30 May 2026 11:10:54 -0700 Subject: [PATCH 5/5] Fix stale section flags in README: remove nonexistent -x/--exclude, document -D/-S/--columns/--fields The -x/--exclude option no longer exists; section selection is -S (aliases -s/--select/--section). Update the Common Flags table and Output Control section to reflect the current discovery (-D) and projection (--columns/--fields) model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50380126..338e5f5c 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ A bare name like `dotnet-inspect System.Text.Json` uses a router to pick the bes | `--platform` | Search all platform frameworks (find, extensions, implements) | | `--json` | JSON output | | `--mermaid` | Mermaid diagram output (`depends` command) | -| `-s Name` | Include section (glob-capable: `-s Ext*`) | -| `-x Name` | Exclude section | +| `-D [Name]` | Discover schema — bare lists sections, `-D Section` lists its columns/fields | +| `-s Name` / `-S Name` | Select section(s) by name (glob-capable: `-S Ext*`); bare lists sections | +| `--columns Names` / `--fields Names` | Project specific columns/fields within a selected section | | `--shape` | Type shape diagram (hierarchy + members) — `type` command | | `--all` | Include non-public, hidden, and obsolete members | | `--docs` / `--no-docs` | Control XML docs — `member` has docs on by default | @@ -249,7 +250,7 @@ dotnet-inspect -v:q # Command names only (onelin **Verbosity** (`-v`): q(uiet) → m(inimal) → n(ormal) → d(etailed). Controls which sections are included. -**Sections**: Use `-s Name` to include or `-x Name` to exclude sections by name. Bare `-s` lists available sections. Supports glob patterns (`-s Ext*`). +**Sections**: Use `-S Name` (alias `-s`) to select one or more sections by name; a bare `-S` lists available sections. Supports glob patterns (`-S Ext*`) and comma/semicolon-separated lists. Use `-D` to discover the schema (sections, then a section's columns/fields), and `--columns`/`--fields` to project specific columns or fields within a section. **JSON**: `--json` for full JSON, `--json --compact` for minified.