Add unified platform map for cross-platform API documentation#4327
Add unified platform map for cross-platform API documentation#4327AlexDaines wants to merge 5 commits intodevelopmentfrom
Conversation
Implements PlatformAvailabilityMap infrastructure for tracking API availability across .NET platforms (net472, netstandard2.0, netcoreapp3.1, net8.0). Key features: - Unified platform map builder that scans all target framework assemblies - O(1) lookup for member platform availability - Support for documenting platform-exclusive APIs (e.g., H2 eventstream methods) - Supplemental manifest pattern for including net8.0-only methods in docs Badge rendering deferred to follow-up PR pending UX design review.
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive platform availability mapping infrastructure to the AWS .NET SDK documentation generator to track API availability across different .NET platforms (net472, netstandard2.0, netcoreapp3.1, net8.0). This addresses issue #3938 where customers encountered documented APIs that don't exist on their target platform.
Changes:
- Implements a unified platform availability map that scans all platforms upfront and maintains signature-to-platform mappings
- Adds isolated assembly loading via
AssemblyLoadContextto enable loading multiple platform versions of the same assembly simultaneously - Introduces infrastructure for generating documentation pages for platform-exclusive APIs (e.g., H2 bidirectional streaming methods only available in net8.0)
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| PlatformMap/*.cs | New infrastructure for tracking member availability across platforms, including map builder, entry objects, assembly contexts, and signature generation utilities |
| SdkDocGenerator.cs | Main execution flow updated to build platform maps upfront, generate exclusive content, and provide legacy fallback path |
| GenerationManifest.cs | Extended to support platform maps, supplemental manifests, and exclusive page generation |
| GeneratorOptions.cs | Added UseLegacySupplemental option with deprecation notice for rollback safety |
| ReflectionWrappers.cs | Added IsolatedAssemblyLoadContext for loading same-named assemblies from different platforms |
| NDocUtilities.cs | Extended signature generation helpers and added duplicate key protection for multi-platform doc loading |
| ClassWriter.cs | Updated to merge supplemental methods from other platforms into class documentation pages |
| BaseWriter.cs | Added platform display name mapping for user-friendly badge rendering |
| CommandLineParser.cs | Added CLI flag for legacy supplemental mode |
| SDKDocGeneratorLib.csproj | Added System.Runtime.Loader dependency for assembly isolation |
| sdkstyle.css | Minor formatting fix (removed trailing whitespace) |
| <PackageReference Include="Microsoft.CSharp" Version="4.7.0" /> | ||
| <PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" /> | ||
| <PackageReference Include="Microsoft.AspNetCore.SystemWebAdapters" Version="1.3.0" /> | ||
| <PackageReference Include="System.Runtime.Loader" Version="4.3.0" /> |
There was a problem hiding this comment.
The project targets netstandard2.0 but uses AssemblyLoadContext which is not fully supported on .NET Framework when running under netstandard2.0. The System.Runtime.Loader package version 4.3.0 provides the API surface but the actual unload functionality (used in IsolatedAssemblyLoadContext and referenced in comments about "Full unloading is only available in .NET Core 3.0+") will not work on .NET Framework hosts.
This could cause assembly loading conflicts or memory leaks when the doc generator runs on .NET Framework. Consider either:
- Multi-targeting the project to
netstandard2.0;net8.0and using conditional compilation forAssemblyLoadContextfeatures - Using
AppDomainisolation for .NET Framework scenarios as a fallback - Documenting that the doc generator must run on .NET Core 3.1+ for proper assembly unloading
The existing code nulls out references but doesn't truly unload assemblies on .NET Framework, which could be problematic for large doc generation runs.
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics; | ||
| using System.IO; | ||
| using System.Linq; | ||
|
|
||
| namespace SDKDocGenerator.PlatformMap | ||
| { | ||
| /// <summary> | ||
| /// Builds a unified PlatformAvailabilityMap by scanning assemblies across multiple platforms. | ||
| /// | ||
| /// Unified Architecture: | ||
| /// 1. Load ALL platform assemblies upfront (not just signature scan) | ||
| /// 2. Scan all public types and members, recording signatures AND wrappers | ||
| /// 3. Keep assemblies loaded throughout generation (via PlatformAssemblyContext) | ||
| /// 4. For exclusive members, store the MethodInfoWrapper for page generation | ||
| /// 5. Return map with contexts - caller disposes when done | ||
| /// | ||
| /// Memory Model: | ||
| /// - Assemblies stay loaded after BuildMap() returns | ||
| /// - Map holds references to all assembly contexts | ||
| /// - Caller must call map.Dispose() to release resources | ||
| /// - For typical builds (3-4 platforms), memory usage is acceptable | ||
| /// </summary> | ||
| public class PlatformMapBuilder | ||
| { | ||
| private readonly GeneratorOptions _options; | ||
|
|
||
| public PlatformMapBuilder(GeneratorOptions options) | ||
| { | ||
| _options = options ?? throw new ArgumentNullException(nameof(options)); | ||
| } | ||
|
|
||
| #region Public API | ||
|
|
||
| /// <summary> | ||
| /// Builds a complete platform availability map for a service. | ||
| /// Assemblies are kept loaded - caller must dispose the returned map. | ||
| /// </summary> | ||
| /// <param name="serviceName">Service name (e.g., "S3", "Core")</param> | ||
| /// <param name="assemblyName">Assembly name without extension (e.g., "AWSSDK.S3")</param> | ||
| /// <param name="platformsToScan">List of platform folders to scan (e.g., ["net472", "net8.0"])</param> | ||
| /// <returns>PlatformAvailabilityMap with loaded assemblies - must be disposed</returns> | ||
| public PlatformAvailabilityMap BuildMap( | ||
| string serviceName, | ||
| string assemblyName, | ||
| IEnumerable<string> platformsToScan) | ||
| { | ||
| if (string.IsNullOrEmpty(serviceName)) | ||
| throw new ArgumentNullException(nameof(serviceName)); | ||
| if (string.IsNullOrEmpty(assemblyName)) | ||
| throw new ArgumentNullException(nameof(assemblyName)); | ||
| if (platformsToScan == null) | ||
| throw new ArgumentNullException(nameof(platformsToScan)); | ||
|
|
||
| var platforms = platformsToScan.ToList(); | ||
| if (!platforms.Any()) | ||
| throw new ArgumentException("Must specify at least one platform to scan", nameof(platformsToScan)); | ||
|
|
||
| if (_options.Verbose) | ||
| Trace.WriteLine($"Building unified platform map for {serviceName} across {platforms.Count} platforms..."); | ||
|
|
||
| // Accumulator: { signature → PlatformMemberEntry } | ||
| var memberIndex = new Dictionary<string, PlatformMemberEntry>(StringComparer.Ordinal); | ||
| var scannedPlatforms = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| var loadedContexts = new List<PlatformAssemblyContext>(); | ||
|
|
||
| // Primary platform is what the Options specify (usually net472) | ||
| var primaryPlatform = _options.Platform; | ||
|
|
||
| // First pass: Scan primary platform to establish baseline signatures | ||
| var primaryAssemblyPath = Path.GetFullPath(Path.Combine(_options.SDKAssembliesRoot, primaryPlatform, assemblyName + ".dll")); | ||
| if (File.Exists(primaryAssemblyPath)) | ||
| { | ||
| var context = LoadAndScanPlatform( | ||
| primaryAssemblyPath, | ||
| primaryPlatform, | ||
| serviceName, | ||
| memberIndex, | ||
| isPrimary: true); | ||
|
|
||
| if (context != null) | ||
| { | ||
| loadedContexts.Add(context); | ||
| scannedPlatforms.Add(primaryPlatform); | ||
| if (_options.Verbose) | ||
| Trace.WriteLine($" Scanned primary platform {primaryPlatform}: {memberIndex.Count} signatures"); | ||
| } | ||
| } | ||
|
|
||
| // Second pass: Scan supplemental platforms and capture wrappers for exclusive members | ||
| foreach (var platform in platforms) | ||
| { | ||
| if (platform.Equals(primaryPlatform, StringComparison.OrdinalIgnoreCase)) | ||
| continue; // Already scanned | ||
|
|
||
| var assemblyPath = Path.GetFullPath(Path.Combine(_options.SDKAssembliesRoot, platform, assemblyName + ".dll")); | ||
| if (!File.Exists(assemblyPath)) | ||
| { | ||
| if (_options.Verbose) | ||
| Trace.WriteLine($" Skipping {platform}: assembly not found"); | ||
| continue; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var context = LoadAndScanPlatform( | ||
| assemblyPath, | ||
| platform, | ||
| serviceName, | ||
| memberIndex, | ||
| isPrimary: false); | ||
|
|
||
| if (context != null) | ||
| { | ||
| loadedContexts.Add(context); | ||
| scannedPlatforms.Add(platform); | ||
| if (_options.Verbose) | ||
| Trace.WriteLine($" Scanned {platform}: {memberIndex.Count} unique signatures total"); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Trace.WriteLine($" WARNING: Failed to scan {platform}: {ex.Message}"); | ||
| // Continue with other platforms even if one fails | ||
| } | ||
| } | ||
|
|
||
| if (!scannedPlatforms.Any()) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Failed to scan any platforms for {serviceName}. " + | ||
| $"Checked platforms: {string.Join(", ", platforms)}"); | ||
| } | ||
|
|
||
| // After all platforms scanned, identify and store wrappers for exclusive methods | ||
| IdentifyAndStoreExclusiveWrappers(memberIndex, primaryPlatform, loadedContexts); | ||
|
|
||
| var map = new PlatformAvailabilityMap( | ||
| serviceName, | ||
| primaryPlatform, | ||
| memberIndex, | ||
| scannedPlatforms, | ||
| loadedContexts); | ||
|
|
||
| if (_options.Verbose) | ||
| map.WriteToTrace(); | ||
|
|
||
| return map; | ||
| } | ||
|
|
||
| #endregion | ||
|
|
||
| #region Private Loading Logic | ||
|
|
||
| /// <summary> | ||
| /// Loads a platform assembly and scans it, keeping the assembly loaded. | ||
| /// </summary> | ||
| private PlatformAssemblyContext LoadAndScanPlatform( | ||
| string assemblyPath, | ||
| string platform, | ||
| string serviceName, | ||
| Dictionary<string, PlatformMemberEntry> memberIndex, | ||
| bool isPrimary) | ||
| { | ||
| var docId = NDocUtilities.GenerateDocId(serviceName, platform); | ||
| var wrapper = new AssemblyWrapper(docId); | ||
|
|
||
| try | ||
| { | ||
| // Use isolated context to avoid "assembly already loaded" conflicts | ||
| wrapper.LoadAssembly(assemblyPath, useIsolatedContext: true); | ||
|
|
||
| // Scan all types | ||
| foreach (var type in wrapper.GetTypes()) | ||
| { | ||
| ScanType(type, platform, memberIndex); | ||
| } | ||
|
|
||
| // Create context that keeps the assembly alive | ||
| return new PlatformAssemblyContext( | ||
| platform, | ||
| wrapper, | ||
| serviceName, | ||
| assemblyPath, | ||
| isPrimary); | ||
| } | ||
| catch (Exception) | ||
| { | ||
| // If loading fails, clean up and rethrow | ||
| wrapper.Unload(); | ||
| throw; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Scans a single type and all its members, adding to the member index. | ||
| /// </summary> | ||
| private void ScanType( | ||
| TypeWrapper type, | ||
| string platform, | ||
| Dictionary<string, PlatformMemberEntry> memberIndex) | ||
| { | ||
| var typeFullName = type.FullName; | ||
|
|
||
| // Type itself | ||
| RecordMember(MemberSignature.ForType(type), typeFullName, platform, memberIndex); | ||
|
|
||
| // Constructors | ||
| foreach (var ctor in type.GetConstructors()) | ||
| { | ||
| if (ctor.IsPublic) | ||
| { | ||
| RecordMember(MemberSignature.ForConstructor(ctor), typeFullName, platform, memberIndex); | ||
| } | ||
| } | ||
|
|
||
| // Methods (only those we document) | ||
| foreach (var method in type.GetMethodsToDocument()) | ||
| { | ||
| RecordMember(MemberSignature.ForMethod(method), typeFullName, platform, memberIndex); | ||
| } | ||
|
|
||
| // Properties | ||
| foreach (var property in type.GetProperties()) | ||
| { | ||
| if (property.IsPublic) | ||
| { | ||
| RecordMember(MemberSignature.ForProperty(property), typeFullName, platform, memberIndex); | ||
| } | ||
| } | ||
|
|
||
| // Fields | ||
| foreach (var field in type.GetFields()) | ||
| { | ||
| if (field.IsPublic) | ||
| { | ||
| RecordMember(MemberSignature.ForField(field), typeFullName, platform, memberIndex); | ||
| } | ||
| } | ||
|
|
||
| // Events | ||
| foreach (var eventInfo in type.GetEvents()) | ||
| { | ||
| RecordMember(MemberSignature.ForEvent(eventInfo), typeFullName, platform, memberIndex); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Records that a member exists on a platform. | ||
| /// </summary> | ||
| private void RecordMember( | ||
| string signature, | ||
| string declaringTypeFullName, | ||
| string platform, | ||
| Dictionary<string, PlatformMemberEntry> memberIndex) | ||
| { | ||
| if (string.IsNullOrEmpty(signature)) | ||
| return; | ||
|
|
||
| if (!memberIndex.TryGetValue(signature, out var entry)) | ||
| { | ||
| entry = new PlatformMemberEntry(signature, declaringTypeFullName); | ||
| memberIndex[signature] = entry; | ||
| } | ||
|
|
||
| entry.Platforms.Add(platform); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// After all platforms are scanned, identifies exclusive methods and stores their wrappers. | ||
| /// A member is "exclusive" if it exists on some platforms but NOT on the primary platform. | ||
| /// </summary> | ||
| private void IdentifyAndStoreExclusiveWrappers( | ||
| Dictionary<string, PlatformMemberEntry> memberIndex, | ||
| string primaryPlatform, | ||
| List<PlatformAssemblyContext> loadedContexts) | ||
| { | ||
| // Find method entries that are not on primary platform | ||
| foreach (var kvp in memberIndex) | ||
| { | ||
| var entry = kvp.Value; | ||
|
|
||
| // Only handle methods (M: prefix) that are exclusive to non-primary platforms | ||
| if (!entry.IsMethod) | ||
| continue; | ||
|
|
||
| if (entry.Platforms.Contains(primaryPlatform)) | ||
| continue; // Available on primary, not exclusive | ||
|
|
||
| // Find which supplemental platform has this method | ||
| string exclusivePlatform = null; | ||
| foreach (var platform in entry.Platforms) | ||
| { | ||
| if (!platform.Equals(primaryPlatform, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| exclusivePlatform = platform; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (exclusivePlatform == null) | ||
| continue; | ||
|
|
||
| // Get the assembly context for this platform | ||
| var context = loadedContexts.FirstOrDefault(c => | ||
| c.Platform.Equals(exclusivePlatform, StringComparison.OrdinalIgnoreCase)); | ||
|
|
||
| if (context == null) | ||
| { | ||
| if (_options.Verbose) | ||
| Trace.WriteLine($" WARNING: No assembly context for platform '{exclusivePlatform}' while processing {entry.Signature}"); | ||
| continue; | ||
| } | ||
|
|
||
| // Find the method wrapper in the loaded assembly | ||
| var methodWrapper = FindMethodInAssembly(context.Assembly, entry); | ||
|
|
||
| if (methodWrapper != null) | ||
| { | ||
| entry.ExclusiveMethodWrapper = methodWrapper; | ||
| entry.ExclusivePlatform = exclusivePlatform; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Finds a method wrapper in the assembly matching the signature. | ||
| /// </summary> | ||
| private MethodInfoWrapper FindMethodInAssembly(AssemblyWrapper assembly, PlatformMemberEntry entry) | ||
| { | ||
| if (string.IsNullOrEmpty(entry.DeclaringTypeFullName)) | ||
| return null; | ||
|
|
||
| var type = assembly.GetType(entry.DeclaringTypeFullName); | ||
| if (type == null) | ||
| return null; | ||
|
|
||
| // Extract method name from signature | ||
| // Signature format: "M:Namespace.Type.MethodName(ParamTypes)" | ||
| var methodName = MemberSignature.ExtractMethodName(entry.Signature); | ||
| if (string.IsNullOrEmpty(methodName)) | ||
| return null; | ||
|
|
||
| // Find matching method | ||
| foreach (var method in type.GetMethodsToDocument()) | ||
| { | ||
| var sig = MemberSignature.ForMethod(method); | ||
| if (sig == entry.Signature) | ||
| { | ||
| return method; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| #endregion | ||
| } | ||
| } |
There was a problem hiding this comment.
This PR introduces significant new infrastructure (platform availability mapping, isolated assembly loading, member signature generation) but there is no test coverage for any of the new PlatformMap classes. The existing test suite in SDKDocGenerator.UnitTests has tests for signature generation, but the new classes are untested.
Critical areas that should have test coverage include:
PlatformMapBuilder.BuildMap()- Core platform scanning logicPlatformAvailabilityMapquery methods (GetPlatformsForMember, IsMemberPlatformRestricted, etc.)MemberSignaturegeneration for various member typesIsolatedAssemblyLoadContextloading behavior- Edge cases like missing assemblies, invalid signatures, disposal
The PR checklist shows "I have added tests to cover my changes" as unchecked. According to the coding guidelines (CodingGuidelineID: 1000000), automated checks should flag PRs that reduce test coverage for affected code.
| /// DEPRECATION NOTICE: Will be removed Q3 2026 after unified approach validated. | ||
| /// </summary> | ||
| [Obsolete("Use unified platform map (default). Legacy will be removed Q3 2026.")] |
There was a problem hiding this comment.
The deprecation notice states "Will be removed Q3 2026" but the PR is being submitted in early 2026 (February), giving only ~4-5 months for the "rollback safety" period mentioned in the PR description. This is a very short deprecation window for infrastructure code.
Consider extending the deprecation timeline to Q1 or Q2 2027 to provide more time to:
- Validate the unified approach in production
- Allow users who may have tooling depending on specific behaviors time to adapt
- Provide adequate buffer for discovering and fixing edge cases
The custom coding guideline (CodingGuidelineID: 1000000) emphasizes ensuring backward compatibility and documenting behavioral changes. A longer deprecation period would better align with these principles.
| /// DEPRECATION NOTICE: Will be removed Q3 2026 after unified approach validated. | |
| /// </summary> | |
| [Obsolete("Use unified platform map (default). Legacy will be removed Q3 2026.")] | |
| /// DEPRECATION NOTICE: Will be removed no earlier than Q2 2027 after unified approach validated. | |
| /// </summary> | |
| [Obsolete("Use unified platform map (default). Legacy will be removed no earlier than Q2 2027.")] |
| // Find which supplemental platform has this method | ||
| string exclusivePlatform = null; | ||
| foreach (var platform in entry.Platforms) | ||
| { | ||
| if (!platform.Equals(primaryPlatform, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| exclusivePlatform = platform; | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
When a method exists on multiple supplemental platforms but not on the primary platform (e.g., exists on both netstandard2.0 and net8.0 but not on net472), this code arbitrarily selects the first supplemental platform found and breaks. This means:
- Only one platform is recorded as the "ExclusivePlatform" even though the method exists on multiple platforms
- The platform map correctly tracks all platforms in
entry.Platforms, but the exclusive wrapper storage only references one - This could lead to incorrect or incomplete platform badge rendering if a method is on multiple supplemental platforms
Consider either:
- Storing multiple exclusive platform references
- Selecting a "best" platform based on a priority order (e.g., prefer most modern)
- Documenting this behavior and why it's acceptable
- Or verify that the scenario of a method existing on multiple supplemental platforms but not primary is impossible in practice
The current implementation may work if methods are either net472+newer OR net8.0-only, but breaks for more complex platform availability patterns.
Correctness fixes (C1-C4): - Remove dead GenerateSupplementalPagesFromMap() method from GenerationManifest - Remove stale supplemental manifest construction from SdkDocGenerator - Fix PlatformMapBuilder to use deterministic sorted iteration order - Guard PlatformAvailabilityMap wrapper queries with ObjectDisposedException Infrastructure fixes (I1-I5): - Add try-finally in SdkDocGenerator.Execute() to ensure map disposal - Add partial-failure cleanup in PlatformMapBuilder.BuildMap() - Retain disposed map reference for proper disposed-state checks - Use HashSet<string> instead of List for platform deduplication in PlatformMemberEntry - Make MemberSignature.ForMember use switch expression for clarity Maintainability fixes (M1-M4): - Extract platform folder discovery into dedicated helper method - Add XML doc comments to all public PlatformMemberEntry members - Rename ambiguous local variables in PlatformMapBuilder for clarity - Replace magic strings with named constants in SdkDocGenerator
Description
Adds PlatformAvailabilityMap infrastructure for tracking API availability across .NET platforms (net472, netstandard2.0, netcoreapp3.1, net8.0). Enables documentation of platform-exclusive APIs like H2 eventstream methods that only exist in net8.0.
Motivation and Context
Fixes #3938 - Customers see documented APIs that don't exist on their target platform, wasting time trying to use APIs that won't compile.
This PR ships the infrastructure only. Badge UX deferred to follow-up PR pending design review.
Testing
dotnet build SDKDocGenerator/SDKDocGenerator.csprojBreaking Changes Assessment
None. This is additive infrastructure with no changes to existing doc output behavior.
Screenshots (if appropriate)
N/A - Badge rendering deferred to follow-up PR.
Types of changes
Checklist
License