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/README.md b/README.md
index 8da85431..338e5f5c 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
@@ -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 |
@@ -98,6 +99,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
```
@@ -248,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.
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/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
```
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/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..edb27fa3 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;
@@ -590,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('@'))
{
@@ -649,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");
}
@@ -832,26 +849,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/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/PlatformResolverTests.cs b/src/dotnet-inspect.Tests/PlatformResolverTests.cs
index e6e16f6d..0bf15986 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()
{
@@ -89,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()
{
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,