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
1 change: 0 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<Project>
<PropertyGroup>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
11 changes: 11 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>

<!-- Runtime async (compiler "async v2") is only valid on net11.0. Enable it there.
This lives in Directory.Build.targets (not .props) because TargetFramework is
not yet set when .props files are imported, so a net11.0 condition would never
match there. -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net11.0'">
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

</Project>
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dnx dotnet-inspect -y -- <command>

### 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

Expand All @@ -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 |
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Features>runtime-async=on</Features>`
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`.
Expand Down
4 changes: 3 additions & 1 deletion docs/design/section-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,15 @@ 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.

Sections with a `null` scanner key have their data collected unconditionally as part of core metadata loading.

## Library Sections

The library command currently has 14 registered sections:
The library command currently has 15 registered sections:

| Section | MinVerbosity | Scanner Key |
| ------- | ------------ | ----------- |
Expand All @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions docs/workflows/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions skills/dotnet-inspect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
5 changes: 5 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<Project>

<!-- NOTE: The root Directory.Build.targets supplies <Features>runtime-async=on</Features>
for net11.0 builds. This src tree has no Directory.Build.targets, so the root one is
picked up. If a Directory.Build.targets is ever added under src/, it MUST import the
root targets, or runtime async will be silently disabled for net11.0 src builds. -->

<PropertyGroup>
<DefaultTargetFramework Condition="'$(DefaultTargetFramework)' == ''">net10.0</DefaultTargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
47 changes: 42 additions & 5 deletions src/DotnetInspector.Metadata/AssemblyDetailScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
}
}

/// <summary>
Expand All @@ -289,6 +320,12 @@ public class PresenceFlags
public bool HasManifestResources { get; set; }
public bool HasAssemblyAttributes { get; set; }
public bool HasTypeForwarders { get; set; }

/// <summary>Whether the assembly has any public runtime-async methods (impl flag 0x2000).</summary>
public bool HasRuntimeAsync { get; set; }

/// <summary>Whether the assembly has any public classic state-machine async methods.</summary>
public bool HasStateMachineAsync { get; set; }
}


43 changes: 42 additions & 1 deletion src/DotnetInspector.Metadata/MethodClassificationScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ public record ClassifiedMethodInfo(
public enum MethodClassification
{
Unsafe,
PInvoke
PInvoke,

/// <summary>
/// Runtime async method (.NET 11+): MethodImplAttributes.Async (0x2000) flag set,
/// suspension handled by the runtime with no compiler-generated state machine.
/// </summary>
RuntimeAsync,

/// <summary>
/// Classic compiler state-machine async method: carries
/// AsyncStateMachineAttribute or AsyncIteratorStateMachineAttribute.
/// </summary>
StateMachineAsync
}

/// <summary>
Expand Down Expand Up @@ -88,6 +100,15 @@ public static List<ClassifiedMethodInfo> 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
{
Expand All @@ -111,6 +132,26 @@ public static List<ClassifiedMethodInfo> Scan(PEReader peReader)
return results;
}

/// <summary>
/// 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.
/// </summary>
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<string> signature)
{
if (signature.ReturnType.Contains('*'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<!-- PackageReference: releases -->
<ItemGroup>
<PackageReference Include="MarkdownTable.Formatting" />
<PackageReference Include="NuGet.Versioning" />
</ItemGroup>

</Project>
54 changes: 30 additions & 24 deletions src/DotnetInspector.Services/PlatformResolver.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection.Metadata;
using System.Runtime.InteropServices;
using NuGet.Versioning;

namespace DotnetInspector.Services;

Expand Down Expand Up @@ -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('@'))
{
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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);
}

/// <summary>
Expand Down
Loading
Loading