From 03e233f1ddf89d52e45fe77298d60aedc90740b7 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Tue, 19 May 2026 11:44:44 -0700 Subject: [PATCH 1/8] Add PlatformDocAnalyzer for platform-specific library documentation conventions Introduces a Roslyn analyzer (PLATDOC001-003) that enforces documentation placement conventions for platform-specific libraries with UseCompilerGeneratedDocXmlFile=true: - PLATDOC001: Public types must have a source file named TypeName.cs - PLATDOC002: Partial source files must follow TypeName.Something.cs convention - PLATDOC003: Public members in non-primary partial files must not have XML documentation comments (docs should be in TypeName.cs) The analyzer only activates when the TargetFramework has a platform suffix (e.g. -windows, -linux) and UseCompilerGeneratedDocXmlFile is true. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/analyzers/Directory.Build.props | 8 + .../PlatformDocAnalyzer.Tests.csproj | 17 + .../PlatformDocAnalyzerTests.cs | 327 ++++++++++++++++++ .../PlatformDocAnalyzer.cs | 261 ++++++++++++++ .../PlatformDocAnalyzer.csproj | 18 + .../PlatformDocAnalyzer.props | 6 + eng/generators.targets | 11 + 7 files changed, 648 insertions(+) create mode 100644 eng/analyzers/Directory.Build.props create mode 100644 eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj create mode 100644 eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs create mode 100644 eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs create mode 100644 eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj create mode 100644 eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props diff --git a/eng/analyzers/Directory.Build.props b/eng/analyzers/Directory.Build.props new file mode 100644 index 00000000000000..003cb8850bdc7c --- /dev/null +++ b/eng/analyzers/Directory.Build.props @@ -0,0 +1,8 @@ + + + + true + + + + diff --git a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj new file mode 100644 index 00000000000000..ef3f1388ac1a35 --- /dev/null +++ b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(NetCoreAppCurrent) + + + + + + + + + + + + + diff --git a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs new file mode 100644 index 00000000000000..7c63d3434ce1af --- /dev/null +++ b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs @@ -0,0 +1,327 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.DotNet.Analyzers.PlatformDoc; +using Xunit; + +namespace PlatformDocAnalyzer.Tests +{ + using AnalyzerTest = CSharpAnalyzerTest; + + public class PlatformDocAnalyzerTests + { + private static readonly (string Key, string Value)[] s_platformTfmOptions = new[] + { + ("build_property.TargetFramework", "net10.0-windows"), + ("build_property.UseCompilerGeneratedDocXmlFile", "true"), + }; + + private static readonly (string Key, string Value)[] s_nonPlatformTfmOptions = new[] + { + ("build_property.TargetFramework", "net10.0"), + ("build_property.UseCompilerGeneratedDocXmlFile", "true"), + }; + + private static AnalyzerTest CreateTest( + (string Key, string Value)[] globalOptions, + params (string FileName, string Source)[] sources) + { + var test = new AnalyzerTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }; + + foreach ((string fileName, string source) in sources) + { + test.TestState.Sources.Add((fileName, source)); + } + + string optionsText = "is_global = true\r\n"; + foreach ((string key, string value) in globalOptions) + { + optionsText += $"{key} = {value}\r\n"; + } + + test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", optionsText)); + return test; + } + + [Fact] + public async Task NoDiagnosticForNonPlatformTfm() + { + // A non-platform TFM should not trigger any diagnostics even if naming is wrong. + var test = CreateTest( + s_nonPlatformTfmOptions, + ("WrongName.cs", @" +public class Foo +{ + /// Some docs + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticWhenUseCompilerGeneratedDocXmlFileIsFalse() + { + var options = new[] + { + ("build_property.TargetFramework", "net10.0-windows"), + ("build_property.UseCompilerGeneratedDocXmlFile", "false"), + }; + + var test = CreateTest( + options, + ("WrongName.cs", @" +public class Foo +{ + /// Some docs + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForCorrectSetup() + { + // Primary file matches type name, no docs on partial files. + var test = CreateTest( + s_platformTfmOptions, + ("Foo.cs", @" +/// Foo type +public partial class Foo +{ + /// Bar method + public void Bar() { } +}"), + ("Foo.Windows.cs", @" +public partial class Foo +{ + public void PlatformSpecificMethod() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC001_PublicTypeMissingPrimaryFile() + { + var test = CreateTest( + s_platformTfmOptions, + ("Foo.Windows.cs", @" +public partial class {|#0:Foo|} +{ + public void Bar() { } +}"), + ("Foo.Unix.cs", @" +public partial class {|#1:Foo|} +{ + public void Baz() { } +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.MissingPrimaryFileRule) + .WithLocation(0).WithArguments("Foo")); + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.MissingPrimaryFileRule) + .WithLocation(1).WithArguments("Foo")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC002_BadPartialFileName() + { + var test = CreateTest( + s_platformTfmOptions, + ("Foo.cs", @" +/// Foo type +public partial class Foo +{ + /// Bar method + public void Bar() { } +}"), + ("Helpers.cs", @" +public partial class {|#0:Foo|} +{ + public void PlatformSpecificHelper() { } +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.BadPartialFileNameRule) + .WithLocation(0).WithArguments("Helpers.cs", "Foo")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC003_DocsOnNonPrimaryFile() + { + var test = CreateTest( + s_platformTfmOptions, + ("Foo.cs", @" +/// Foo type +public partial class Foo +{ + /// Bar method + public void Bar() { } +}"), + ("Foo.Windows.cs", @" +public partial class Foo +{ + /// This doc should be in Foo.cs + public void {|#0:PlatformSpecificMethod|}() { } +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.DocsOnNonPrimaryFileRule) + .WithLocation(0).WithArguments("PlatformSpecificMethod", "Foo.Windows.cs", "Foo")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC003_DocsOnNonPrimaryFile_MultipleMembers() + { + var test = CreateTest( + s_platformTfmOptions, + ("MyService.cs", @" +/// MyService type +public partial class MyService +{ + /// Start method + public void Start() { } +}"), + ("MyService.Windows.cs", @" +public partial class MyService +{ + /// Windows-specific start + public void {|#0:StartWindows|}() { } + + /// Windows handle + public int {|#1:Handle|} { get; set; } + + // No docs - this is fine + public void InternalHelper() { } +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.DocsOnNonPrimaryFileRule) + .WithLocation(0).WithArguments("StartWindows", "MyService.Windows.cs", "MyService")); + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.DocsOnNonPrimaryFileRule) + .WithLocation(1).WithArguments("Handle", "MyService.Windows.cs", "MyService")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForNonPublicType() + { + var test = CreateTest( + s_platformTfmOptions, + ("WrongName.cs", @" +internal class Foo +{ + /// Some docs + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForPrivateMemberDocs() + { + // Private/internal members in non-primary files can have docs + var test = CreateTest( + s_platformTfmOptions, + ("Foo.cs", @" +/// Foo type +public partial class Foo +{ + /// Bar method + public void Bar() { } +}"), + ("Foo.Windows.cs", @" +public partial class Foo +{ + /// Private helper - docs are fine here + private void PrivateHelper() { } + + /// Internal helper - docs are fine here + internal void InternalHelper() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForNestedType() + { + // Nested types are not checked (only top-level types are) + var test = CreateTest( + s_platformTfmOptions, + ("Outer.cs", @" +/// Outer type +public class Outer +{ + /// Inner type + public class Inner + { + /// Method + public void Method() { } + } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC003_InterfaceMembersAreImplicitlyPublic() + { + var test = CreateTest( + s_platformTfmOptions, + ("IService.cs", @" +/// IService interface +public partial interface IService +{ + /// Start method + void Start(); +}"), + ("IService.Windows.cs", @" +public partial interface IService +{ + /// Windows-specific method + void {|#0:StartWindows|}(); +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.DocsOnNonPrimaryFileRule) + .WithLocation(0).WithArguments("StartWindows", "IService.Windows.cs", "IService")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC001_StructType() + { + var test = CreateTest( + s_platformTfmOptions, + ("WrongName.cs", @" +public struct {|#0:MyStruct|} +{ + public int Value; +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.MissingPrimaryFileRule) + .WithLocation(0).WithArguments("MyStruct")); + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.BadPartialFileNameRule) + .WithLocation(0).WithArguments("WrongName.cs", "MyStruct")); + + await test.RunAsync(CancellationToken.None); + } + } +} diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs new file mode 100644 index 00000000000000..3c499e4f78b245 --- /dev/null +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +#pragma warning disable RS2008 // Not a shipping analyzer + +namespace Microsoft.DotNet.Analyzers.PlatformDoc +{ + /// + /// Ensures platform-specific libraries with UseCompilerGeneratedDocXmlFile=true + /// place their triple-slash documentation on the primary source file so that + /// docs are consistent across platforms. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class PlatformDocAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticIdMissingPrimaryFile = "PLATDOC001"; + public const string DiagnosticIdBadPartialFileName = "PLATDOC002"; + public const string DiagnosticIdDocsOnNonPrimaryFile = "PLATDOC003"; + + public static readonly DiagnosticDescriptor MissingPrimaryFileRule = new( + DiagnosticIdMissingPrimaryFile, + "Public type missing primary source file", + "Public type '{0}' has no source file named '{0}.cs'", + "Documentation", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BadPartialFileNameRule = new( + DiagnosticIdBadPartialFileName, + "Partial source file doesn't follow naming convention", + "Source file '{0}' contains a partial definition of type '{1}' but doesn't follow the '{1}.*.cs' naming convention", + "Documentation", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor DocsOnNonPrimaryFileRule = new( + DiagnosticIdDocsOnNonPrimaryFile, + "XML documentation on non-primary partial file", + "Public member '{0}' in file '{1}' has XML documentation that should be moved to '{2}.cs'", + "Documentation", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(MissingPrimaryFileRule, BadPartialFileNameRule, DocsOnNonPrimaryFileRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + var options = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + + if (!options.TryGetValue("build_property.TargetFramework", out string? tfm) || + string.IsNullOrEmpty(tfm) || + !IsPlatformSpecificTfm(tfm)) + { + return; + } + + if (!options.TryGetValue("build_property.UseCompilerGeneratedDocXmlFile", out string? useCompilerDoc) || + !string.Equals(useCompilerDoc, "true", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private static bool IsPlatformSpecificTfm(string tfm) + { + // Platform-specific TFMs have a platform suffix: net10.0-windows, net9.0-linux, etc. + int dashIndex = tfm.IndexOf('-'); + return dashIndex > 0; + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context) + { + var namedType = (INamedTypeSymbol)context.Symbol; + + if (namedType.DeclaredAccessibility != Accessibility.Public) + return; + + // Only check top-level types, not nested types. + if (namedType.ContainingType is not null) + return; + + ImmutableArray syntaxRefs = namedType.DeclaringSyntaxReferences; + if (syntaxRefs.IsEmpty) + return; + + string typeName = namedType.Name; + string primaryFileName = typeName + ".cs"; + + bool hasPrimaryFile = false; + var nonPrimaryRefs = new List<(SyntaxReference SyntaxRef, string FileName)>(); + + foreach (SyntaxReference syntaxRef in syntaxRefs) + { + string filePath = syntaxRef.SyntaxTree.FilePath; + string fileName = Path.GetFileName(filePath); + + if (string.Equals(fileName, primaryFileName, StringComparison.OrdinalIgnoreCase)) + { + hasPrimaryFile = true; + } + else + { + nonPrimaryRefs.Add((syntaxRef, fileName)); + } + } + + // PLATDOC001: Public type must have a primary source file named TypeName.cs + if (!hasPrimaryFile) + { + foreach (SyntaxReference syntaxRef in syntaxRefs) + { + SyntaxNode node = syntaxRef.GetSyntax(context.CancellationToken); + if (node is TypeDeclarationSyntax typeDecl) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingPrimaryFileRule, + typeDecl.Identifier.GetLocation(), + typeName)); + } + } + } + + foreach ((SyntaxReference syntaxRef, string fileName) in nonPrimaryRefs) + { + SyntaxNode node = syntaxRef.GetSyntax(context.CancellationToken); + if (node is not TypeDeclarationSyntax typeDecl) + continue; + + // PLATDOC002: Non-primary files must follow TypeName.Something.cs convention + string prefix = typeName + "."; + if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + !fileName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || + fileName.Length <= prefix.Length + ".cs".Length - 1) + { + context.ReportDiagnostic(Diagnostic.Create( + BadPartialFileNameRule, + typeDecl.Identifier.GetLocation(), + fileName, + typeName)); + } + + // PLATDOC003: Public members in non-primary files must not have XML docs + CheckMembersForDocs(context, typeDecl, fileName, typeName); + } + } + + private static void CheckMembersForDocs( + SymbolAnalysisContext context, + TypeDeclarationSyntax typeDecl, + string fileName, + string typeName) + { + foreach (MemberDeclarationSyntax member in typeDecl.Members) + { + if (!IsEffectivelyPublic(member, typeDecl)) + continue; + + if (HasXmlDocComment(member)) + { + string memberName = GetMemberName(member); + context.ReportDiagnostic(Diagnostic.Create( + DocsOnNonPrimaryFileRule, + GetMemberIdentifierLocation(member), + memberName, + fileName, + typeName)); + } + } + } + + private static bool IsEffectivelyPublic(MemberDeclarationSyntax member, TypeDeclarationSyntax containingType) + { + // Interface members are implicitly public + if (containingType is InterfaceDeclarationSyntax) + return true; + + // Enum members are implicitly public + if (member is EnumMemberDeclarationSyntax) + return true; + + return member.Modifiers.Any(SyntaxKind.PublicKeyword); + } + + private static bool HasXmlDocComment(MemberDeclarationSyntax member) + { + foreach (SyntaxTrivia trivia in member.GetLeadingTrivia()) + { + SyntaxKind kind = trivia.Kind(); + if (kind == SyntaxKind.SingleLineDocumentationCommentTrivia || + kind == SyntaxKind.MultiLineDocumentationCommentTrivia) + { + return true; + } + } + + return false; + } + + private static Location GetMemberIdentifierLocation(MemberDeclarationSyntax member) + { + return member switch + { + MethodDeclarationSyntax m => m.Identifier.GetLocation(), + PropertyDeclarationSyntax p => p.Identifier.GetLocation(), + EventDeclarationSyntax e => e.Identifier.GetLocation(), + EventFieldDeclarationSyntax ef => ef.Declaration.Variables.FirstOrDefault()?.Identifier.GetLocation() ?? member.GetLocation(), + FieldDeclarationSyntax f => f.Declaration.Variables.FirstOrDefault()?.Identifier.GetLocation() ?? member.GetLocation(), + ConstructorDeclarationSyntax c => c.Identifier.GetLocation(), + DestructorDeclarationSyntax d => d.Identifier.GetLocation(), + IndexerDeclarationSyntax i => i.ThisKeyword.GetLocation(), + OperatorDeclarationSyntax o => o.OperatorToken.GetLocation(), + ConversionOperatorDeclarationSyntax c => c.Type.GetLocation(), + TypeDeclarationSyntax t => t.Identifier.GetLocation(), + DelegateDeclarationSyntax d => d.Identifier.GetLocation(), + EnumMemberDeclarationSyntax e => e.Identifier.GetLocation(), + _ => member.GetLocation() + }; + } + + private static string GetMemberName(MemberDeclarationSyntax member) + { + return member switch + { + MethodDeclarationSyntax m => m.Identifier.Text, + PropertyDeclarationSyntax p => p.Identifier.Text, + EventDeclarationSyntax e => e.Identifier.Text, + EventFieldDeclarationSyntax ef => ef.Declaration.Variables.FirstOrDefault()?.Identifier.Text ?? "", + FieldDeclarationSyntax f => f.Declaration.Variables.FirstOrDefault()?.Identifier.Text ?? "", + ConstructorDeclarationSyntax c => c.Identifier.Text, + DestructorDeclarationSyntax d => "~" + d.Identifier.Text, + IndexerDeclarationSyntax => "this[]", + OperatorDeclarationSyntax o => "operator " + o.OperatorToken.Text, + ConversionOperatorDeclarationSyntax c => c.ImplicitOrExplicitKeyword.Text + " operator " + c.Type, + TypeDeclarationSyntax t => t.Identifier.Text, + DelegateDeclarationSyntax d => d.Identifier.Text, + EnumMemberDeclarationSyntax e => e.Identifier.Text, + _ => "" + }; + } + } +} diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj new file mode 100644 index 00000000000000..82e0a971cb811c --- /dev/null +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + true + latest + enable + + $(NoWarn);RS2008;RS1038 + cs + true + + + + + + + diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props new file mode 100644 index 00000000000000..b99bc6b600b509 --- /dev/null +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props @@ -0,0 +1,6 @@ + + + + + + diff --git a/eng/generators.targets b/eng/generators.targets index 3290f37580be29..08d445786c8647 100644 --- a/eng/generators.targets +++ b/eng/generators.targets @@ -93,4 +93,15 @@ + + + + + + + From 9e9ed55b26e311e66ca94304fc4deb564d0ec2b4 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Tue, 19 May 2026 12:35:58 -0700 Subject: [PATCH 2/8] Fix PlatformDocAnalyzer false positives - Only check types declared across multiple files (partial types split into separate files). Single-file types don't have doc placement issues. - Exclude nested type declarations from PLATDOC003 since they define their own documentation scope. - Suppress PLATDOC002 on System.Private.Xml Async partial files that use the established TypeNameAsync.cs naming convention. - Suppress PLATDOC003 on XmlResolver.FileSystemResolver.cs and XmlResolver.ThrowingResolver.cs which contain non-platform-specific public properties organized by convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PlatformDocAnalyzerTests.cs | 56 ++++++++++++++++++- .../PlatformDocAnalyzer.cs | 15 +++++ .../src/System/Xml/Core/XmlReaderAsync.cs | 2 + .../src/System/Xml/Core/XmlWriterAsync.cs | 2 + .../Resolvers/XmlPreloadedResolverAsync.cs | 2 + .../Xml/XmlResolver.FileSystemResolver.cs | 2 + .../Xml/XmlResolver.ThrowingResolver.cs | 2 + 7 files changed, 80 insertions(+), 1 deletion(-) diff --git a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs index 7c63d3434ce1af..1fa53dc8474326 100644 --- a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs +++ b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs @@ -311,15 +311,69 @@ public async Task PLATDOC001_StructType() var test = CreateTest( s_platformTfmOptions, ("WrongName.cs", @" -public struct {|#0:MyStruct|} +public partial struct {|#0:MyStruct|} { public int Value; +}"), + ("WrongName2.cs", @" +public partial struct {|#1:MyStruct|} +{ + public int Value2; }")); test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.MissingPrimaryFileRule) .WithLocation(0).WithArguments("MyStruct")); + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.MissingPrimaryFileRule) + .WithLocation(1).WithArguments("MyStruct")); test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.BadPartialFileNameRule) .WithLocation(0).WithArguments("WrongName.cs", "MyStruct")); + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.BadPartialFileNameRule) + .WithLocation(1).WithArguments("WrongName2.cs", "MyStruct")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForSingleFileType() + { + // A type defined in only one file should not trigger any diagnostics, + // even if the file name doesn't match the type name. + var test = CreateTest( + s_platformTfmOptions, + ("Helpers.cs", @" +/// Some type +public class Foo +{ + /// Some method + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task NoDiagnosticForNestedTypeDocsOnNonPrimaryFile() + { + // Nested type declarations define their own doc scope and should not be flagged. + var test = CreateTest( + s_platformTfmOptions, + ("Outer.cs", @" +/// Outer type +public partial class Outer +{ + /// Method + public void Method() { } +}"), + ("Outer.Inner.cs", @" +public partial class Outer +{ + /// Nested type docs are fine here + public class Inner + { + /// Inner method + public void InnerMethod() { } + } +}")); await test.RunAsync(CancellationToken.None); } diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs index 3c499e4f78b245..031f35eec83bd2 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs @@ -103,6 +103,17 @@ private static void AnalyzeNamedType(SymbolAnalysisContext context) if (syntaxRefs.IsEmpty) return; + // Only check types declared across multiple files. + // A type in a single file doesn't have a doc placement problem. + var distinctFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (SyntaxReference syntaxRef in syntaxRefs) + { + distinctFiles.Add(syntaxRef.SyntaxTree.FilePath); + } + + if (distinctFiles.Count <= 1) + return; + string typeName = namedType.Name; string primaryFileName = typeName + ".cs"; @@ -172,6 +183,10 @@ private static void CheckMembersForDocs( { foreach (MemberDeclarationSyntax member in typeDecl.Members) { + // Nested type declarations define their own doc scope; skip them. + if (member is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax) + continue; + if (!IsEffectivelyPublic(member, typeDecl)) continue; diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs index f4eae45cba12e9..91fc8ad003d6ae 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs @@ -8,6 +8,8 @@ using System.Threading.Tasks; using System.Xml.Schema; +#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention + namespace System.Xml { // Represents a reader that provides fast, non-cached forward only stream access to XML data. diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs index 6bb887db392dc7..c163293903a93e 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs @@ -6,6 +6,8 @@ using System.Xml.Schema; using System.Xml.XPath; +#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention + namespace System.Xml { // Represents a writer that provides fast non-cached forward-only way of generating XML streams containing XML documents diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs index 09f53c552b5391..a99eb19fbf97ac 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs @@ -5,6 +5,8 @@ using System.Threading.Tasks; using System.Xml; +#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention + namespace System.Xml.Resolvers { // diff --git a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs index 8785d09e9c12cb..df97bb3963d640 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs @@ -4,6 +4,8 @@ using System.IO; using System.Threading.Tasks; +#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention + namespace System.Xml { public abstract partial class XmlResolver diff --git a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs index 674ddb630e0896..09261ae7d17e7f 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs @@ -4,6 +4,8 @@ using System.Net; using System.Threading.Tasks; +#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention + namespace System.Xml { public abstract partial class XmlResolver From fd9a9e2f6a9747d174d568678e0b309f3ee78160 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Tue, 19 May 2026 13:09:55 -0700 Subject: [PATCH 3/8] Document platform-specific documentation placement rules Add a section to adding-api-guidelines.md explaining why documentation must be placed on the primary source file (TypeName.cs) for platform- specific libraries, and how the PlatformDocAnalyzer enforces this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../adding-api-guidelines.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/coding-guidelines/adding-api-guidelines.md b/docs/coding-guidelines/adding-api-guidelines.md index 3854dca911f82d..1ad997c95c9d9c 100644 --- a/docs/coding-guidelines/adding-api-guidelines.md +++ b/docs/coding-guidelines/adding-api-guidelines.md @@ -58,6 +58,41 @@ The rest of the documentation workflow depends on whether the assembly has the ` - Triple-slash comments in source code are synced to dotnet-api-docs periodically (every preview). - More recently introduced libraries typically follow this workflow. +### Documentation placement in platform-specific libraries + +When a library targets platform-specific frameworks (e.g. `net11.0-windows`, `net11.0-linux`), +only **one** platform's compiler-generated doc XML is selected as the source of truth and shipped +to all customers in the IntelliSense package. This means that if XML doc comments for a public API +appear only in a platform-specific partial file, they may be missing from the shipped docs on other +platforms. + +To ensure consistent documentation across all platforms, follow these rules: + +1. **Place docs on the primary source file.** Each public type should have a primary source file + named `TypeName.cs`. All public API documentation (`/// `, `/// `, etc.) must + be placed in this file. +2. **Follow the naming convention for partial files.** Platform-specific or feature-specific + partials must follow the `TypeName.Something.cs` naming convention (e.g. `Socket.Windows.cs`, + `Socket.Unix.cs`). +3. **Do not add public XML doc comments in non-primary partial files.** If a public member is + declared in a file like `TypeName.Windows.cs`, its documentation should be in `TypeName.cs` + (using a partial method declaration or ``), not in the platform-specific file. + +These rules are enforced by the **PlatformDocAnalyzer** (`eng/analyzers/PlatformDocAnalyzer`), +which is automatically applied to all library source projects. The analyzer only activates when +building a platform-specific target framework with `UseCompilerGeneratedDocXmlFile=true`, and +produces the following diagnostics: + +| Diagnostic | Description | +|------------|-------------| +| PLATDOC001 | Public type has no source file named `TypeName.cs`. | +| PLATDOC002 | Partial source file doesn't follow the `TypeName.Something.cs` naming convention. | +| PLATDOC003 | Public member in a non-primary partial file has XML documentation that should be moved to `TypeName.cs`. | + +If a file legitimately doesn't follow these conventions (e.g. an `Async` partial using the +established `TypeNameAsync.cs` pattern), suppress the specific diagnostic with +`#pragma warning disable PLATDOCnnn` and a brief comment explaining why. + **For libraries with `false`:** - The [dotnet-api-docs](https://github.com/dotnet/dotnet-api-docs) repo is the source of truth for documentation. - Triple-slash comments in source code are synced to dotnet-api-docs **only once** for newly introduced APIs. After the initial sync, all subsequent documentation From 8a605f1973b4ae2fcaa2e111a88f9a561522ce50 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Tue, 19 May 2026 14:40:35 -0700 Subject: [PATCH 4/8] Add PLATDOC004: canonical doc XML comparison for platform-specific builds When a library targets both a platform-agnostic TFM (e.g. net11.0) and platform-specific TFMs (e.g. net11.0-windows), the build now passes the canonical TFM's compiler-generated doc XML to the PlatformDocAnalyzer. The analyzer compares each public API's documentation against the canonical and reports PLATDOC004 when they differ. This directly validates the goal (consistent docs across platforms) rather than relying solely on file-naming heuristics. The canonical doc XML is located at its expected artifact path rather than via ProjectReference, avoiding circular dependencies with PNSE builds and the overhead of GetTargetFrameworks calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../adding-api-guidelines.md | 7 + .../PlatformDocAnalyzerTests.cs | 130 ++++++++++++++++ .../PlatformDocAnalyzer.cs | 143 +++++++++++++++++- .../PlatformDocAnalyzer.csproj | 2 +- .../PlatformDocAnalyzer.props | 1 + eng/intellisense.targets | 26 ++++ 6 files changed, 307 insertions(+), 2 deletions(-) diff --git a/docs/coding-guidelines/adding-api-guidelines.md b/docs/coding-guidelines/adding-api-guidelines.md index 1ad997c95c9d9c..a276c3af927b7e 100644 --- a/docs/coding-guidelines/adding-api-guidelines.md +++ b/docs/coding-guidelines/adding-api-guidelines.md @@ -88,6 +88,13 @@ produces the following diagnostics: | PLATDOC001 | Public type has no source file named `TypeName.cs`. | | PLATDOC002 | Partial source file doesn't follow the `TypeName.Something.cs` naming convention. | | PLATDOC003 | Public member in a non-primary partial file has XML documentation that should be moved to `TypeName.cs`. | +| PLATDOC004 | Documentation for a public API differs from the canonical (platform-agnostic) build. | + +PLATDOC001–003 are heuristic rules that guide source organization. PLATDOC004 is an authoritative +check: when a project also targets a platform-agnostic TFM (e.g. `net11.0` alongside +`net11.0-windows`), the build passes the canonical TFM's compiler-generated doc XML to the +analyzer, which compares each public API's documentation against it. Any mismatch indicates that +docs were placed on platform-specific source and will be inconsistent across platforms. If a file legitimately doesn't follow these conventions (e.g. an `Async` partial using the established `TypeNameAsync.cs` pattern), suppress the specific diagnostic with diff --git a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs index 1fa53dc8474326..6589200d80d811 100644 --- a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs +++ b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzerTests.cs @@ -377,5 +377,135 @@ public void InnerMethod() { } await test.RunAsync(CancellationToken.None); } + + private static AnalyzerTest CreateTestWithCanonicalDoc( + (string Key, string Value)[] globalOptions, + string canonicalDocXml, + params (string FileName, string Source)[] sources) + { + var test = CreateTest(globalOptions, sources); + + // Replace the existing globalconfig with one that also configures the additional file. + test.TestState.AnalyzerConfigFiles.Clear(); + string optionsText = "is_global = true\r\n"; + foreach ((string key, string value) in globalOptions) + { + optionsText += $"{key} = {value}\r\n"; + } + optionsText += "[/canonical.xml]\r\n"; + optionsText += "build_metadata.AdditionalFiles.PlatformDocCanonical = true\r\n"; + test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", optionsText)); + + test.TestState.AdditionalFiles.Add(("/canonical.xml", canonicalDocXml)); + return test; + } + + [Fact] + public async Task PLATDOC004_DocsMatchCanonical_NoDiagnostic() + { + string canonicalDocXml = @" + + TestAssembly + + + Foo type + + + Bar method + + +"; + + var test = CreateTestWithCanonicalDoc( + s_platformTfmOptions, + canonicalDocXml, + ("Foo.cs", @" +/// Foo type +public class Foo +{ + /// Bar method + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC004_DocsDifferFromCanonical() + { + string canonicalDocXml = @" + + TestAssembly + + + Foo type + + + Bar method + + +"; + + var test = CreateTestWithCanonicalDoc( + s_platformTfmOptions, + canonicalDocXml, + ("Foo.cs", @" +/// Foo type +public class Foo +{ + /// DIFFERENT Bar method docs + public void {|#0:Bar|}() { } +}")); + + test.ExpectedDiagnostics.Add(new DiagnosticResult(Microsoft.DotNet.Analyzers.PlatformDoc.PlatformDocAnalyzer.DocMismatchRule) + .WithLocation(0).WithArguments("Foo.Bar()")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC004_PlatformOnlyApi_NoDiagnostic() + { + // APIs that don't exist in the canonical doc XML are platform-specific and can have any docs. + string canonicalDocXml = @" + + TestAssembly + + + Foo type + + +"; + + var test = CreateTestWithCanonicalDoc( + s_platformTfmOptions, + canonicalDocXml, + ("Foo.cs", @" +/// Foo type +public class Foo +{ + /// Platform-specific method not in canonical + public void WindowsOnly() { } +}")); + + await test.RunAsync(CancellationToken.None); + } + + [Fact] + public async Task PLATDOC004_NoCanonicalDoc_NoDiagnostic() + { + // When no canonical doc XML is provided, PLATDOC004 should not fire. + var test = CreateTest( + s_platformTfmOptions, + ("Foo.cs", @" +/// DIFFERENT docs +public class Foo +{ + /// Some method + public void Bar() { } +}")); + + await test.RunAsync(CancellationToken.None); + } } } diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs index 031f35eec83bd2..43c0010c7e722e 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs @@ -6,10 +6,13 @@ using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; #pragma warning disable RS2008 // Not a shipping analyzer @@ -26,6 +29,7 @@ public sealed class PlatformDocAnalyzer : DiagnosticAnalyzer public const string DiagnosticIdMissingPrimaryFile = "PLATDOC001"; public const string DiagnosticIdBadPartialFileName = "PLATDOC002"; public const string DiagnosticIdDocsOnNonPrimaryFile = "PLATDOC003"; + public const string DiagnosticIdDocMismatch = "PLATDOC004"; public static readonly DiagnosticDescriptor MissingPrimaryFileRule = new( DiagnosticIdMissingPrimaryFile, @@ -51,8 +55,16 @@ public sealed class PlatformDocAnalyzer : DiagnosticAnalyzer DiagnosticSeverity.Warning, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor DocMismatchRule = new( + DiagnosticIdDocMismatch, + "Documentation differs from canonical platform-agnostic build", + "Documentation for '{0}' differs from the canonical (platform-agnostic) build. Ensure docs are on shared source so they are consistent across platforms.", + "Documentation", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(MissingPrimaryFileRule, BadPartialFileNameRule, DocsOnNonPrimaryFileRule); + ImmutableArray.Create(MissingPrimaryFileRule, BadPartialFileNameRule, DocsOnNonPrimaryFileRule, DocMismatchRule); public override void Initialize(AnalysisContext context) { @@ -79,6 +91,135 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) } context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + + // Look for the canonical doc XML in AdditionalFiles. + Dictionary? canonicalDocs = TryLoadCanonicalDocs(context); + if (canonicalDocs is not null) + { + context.RegisterSymbolAction( + ctx => AnalyzeDocConsistency(ctx, canonicalDocs), + SymbolKind.NamedType); + } + } + + private static Dictionary? TryLoadCanonicalDocs(CompilationStartAnalysisContext context) + { + foreach (AdditionalText file in context.Options.AdditionalFiles) + { + AnalyzerConfigOptions fileOptions = context.Options.AnalyzerConfigOptionsProvider.GetOptions(file); + if (!fileOptions.TryGetValue("build_metadata.AdditionalFiles.PlatformDocCanonical", out string? value) || + !string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + SourceText? text = file.GetText(context.CancellationToken); + if (text is null) + continue; + + return ParseDocXml(text.ToString()); + } + + return null; + } + + private static Dictionary ParseDocXml(string xml) + { + var docs = new Dictionary(StringComparer.Ordinal); + + // Use regex to extract member elements to avoid XML parser normalization + // that could cause false mismatches with GetDocumentationCommentXml() output. + foreach (Match match in Regex.Matches(xml, @"(.*?)", RegexOptions.Singleline)) + { + string name = match.Groups[1].Value; + string innerXml = match.Groups[2].Value; + docs[name] = NormalizeDocXml(innerXml); + } + + return docs; + } + + private static void AnalyzeDocConsistency( + SymbolAnalysisContext context, + Dictionary canonicalDocs) + { + ISymbol symbol = context.Symbol; + + if (symbol is not INamedTypeSymbol namedType) + return; + + if (namedType.DeclaredAccessibility != Accessibility.Public) + return; + + // Check the type itself and all its public members. + CheckSymbolDoc(context, namedType, canonicalDocs); + + foreach (ISymbol member in namedType.GetMembers()) + { + if (member.DeclaredAccessibility != Accessibility.Public) + continue; + + // Skip accessors; they're covered by the property/event. + if (member is IMethodSymbol { AssociatedSymbol: not null }) + continue; + + CheckSymbolDoc(context, member, canonicalDocs); + } + } + + private static void CheckSymbolDoc( + SymbolAnalysisContext context, + ISymbol symbol, + Dictionary canonicalDocs) + { + string? docId = symbol.GetDocumentationCommentId(); + if (docId is null) + return; + + if (!canonicalDocs.TryGetValue(docId, out string? canonicalDoc)) + return; + + string currentDoc = NormalizeDocXml( + symbol.GetDocumentationCommentXml(expandIncludes: true, cancellationToken: context.CancellationToken) ?? ""); + + // Strip the outer wrapper from GetDocumentationCommentXml() output. + currentDoc = StripMemberWrapper(currentDoc); + + if (string.Equals(canonicalDoc, currentDoc, StringComparison.Ordinal)) + return; + + // Both empty → no mismatch. + if (string.IsNullOrWhiteSpace(canonicalDoc) && string.IsNullOrWhiteSpace(currentDoc)) + return; + + Location location = symbol.Locations.FirstOrDefault() ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + DocMismatchRule, + location, + symbol.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + } + + private static string StripMemberWrapper(string xml) + { + // GetDocumentationCommentXml() returns: + // ..inner.. + // We need just the inner content to compare against the parsed doc XML. + const string memberStart = "', xml.IndexOf(memberStart, StringComparison.Ordinal) + 1); + int endIdx = xml.LastIndexOf(memberEnd, StringComparison.Ordinal); + + if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) + return NormalizeDocXml(xml); + + return NormalizeDocXml(xml.Substring(startIdx + 1, endIdx - startIdx - 1)); + } + + private static string NormalizeDocXml(string xml) + { + // Normalize whitespace: collapse runs of whitespace into single spaces, trim. + return Regex.Replace(xml, @"\s+", " ").Trim(); } private static bool IsPlatformSpecificTfm(string tfm) diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj index 82e0a971cb811c..f16a96838fc102 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.csproj @@ -6,7 +6,7 @@ latest enable - $(NoWarn);RS2008;RS1038 + $(NoWarn);RS2008;RS1012;RS1038 cs true diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props index b99bc6b600b509..83923f3105f339 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.props @@ -2,5 +2,6 @@ + diff --git a/eng/intellisense.targets b/eng/intellisense.targets index 1de45098a8fa0a..9579314846d1bf 100644 --- a/eng/intellisense.targets +++ b/eng/intellisense.targets @@ -153,4 +153,30 @@ + + + + + + + + <_CanonicalDocTfm>$(TargetFramework.Substring(0, $(TargetFramework.IndexOf('-')))) + <_CanonicalDocXmlPath>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', '$(MSBuildProjectName)', '$(Configuration)', '$(_CanonicalDocTfm)', '$(AssemblyName).xml')) + + + + + + + From f07a817342e034ee7a010161e2a8d6aca2f937f3 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Wed, 20 May 2026 10:50:51 -0700 Subject: [PATCH 5/8] Unify PNSE and platform-specific canonical doc source resolution Replace the separate GetPNSEDocTargetFramework, AddProjectReferenceTo- PNSEDocSource, and ResolveCanonicalDocSource targets with a single unified flow: GetCanonicalDocSourceTargetFramework -> AddCanonicalDoc- SourceReference -> ConsumeCanonicalDocSource. Both PNSE and platform-specific builds now resolve the same canonical doc source via ProjectReference, guaranteeing it builds first. The canonical doc source has IsCandidateCompilerGeneratedDocFile=true, meaning it never adds a doc-source reference of its own -- so no cycles. For PNSE builds the canonical doc is used as DocFileOverride; for platform-specific builds it is passed to PlatformDocAnalyzer as an AdditionalFile for PLATDOC004 comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/intellisense.targets | 124 +++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 65 deletions(-) diff --git a/eng/intellisense.targets b/eng/intellisense.targets index 9579314846d1bf..64951904f906e7 100644 --- a/eng/intellisense.targets +++ b/eng/intellisense.targets @@ -87,96 +87,90 @@ - - - + + + + + - + - - + + + + + + - + - @(_PNSECandidateTargetFramework) + @(_NonPlatformDocCandidate) + @(_AnyDocCandidate) - - + - - - - - + Condition="'$(UseCompilerGeneratedDocXmlFile)' == 'true' and + '$(CanonicalDocSourceTargetFramework)' != '' and + '$(CanonicalDocSourceTargetFramework)' != '$(TargetFramework)' and + ('$(BuildTargetFramework)' == '' or '$(TargetFrameworkIdentifier)' == '.NETCoreApp')"> + + + + + OutputItemType="CanonicalDocSource" /> - + - - - @(PNSEDocSource->'%(DocFileItem)') - - - - - - - - - - - <_CanonicalDocTfm>$(TargetFramework.Substring(0, $(TargetFramework.IndexOf('-')))) - <_CanonicalDocXmlPath>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', '$(MSBuildProjectName)', '$(Configuration)', '$(_CanonicalDocTfm)', '$(AssemblyName).xml')) + Condition="'@(CanonicalDocSource)' != ''"> + + + @(CanonicalDocSource->'%(DocFileItem)') - - + + - + \ No newline at end of file From 23933bddbba04aceb69c7c3fcac6c96c62cf8ef0 Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Wed, 20 May 2026 13:40:27 -0700 Subject: [PATCH 6/8] Address PR feedback - Narrow #pragma suppressions to minimal scope with restore in all 5 System.Private.Xml files. - Fix net11.0-linux TFM examples to use valid platform TFMs (windows, ios) in docs and code comments. - Remove unreachable TypeDeclarationSyntax and DelegateDeclarationSyntax switch arms from GetMemberName/GetMemberIdentifierLocation since CheckMembersForDocs skips nested type declarations. - Add eng/analyzers/README.md noting test run instructions and that analyzer tests follow the existing convention of running locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PlatformDocAnalyzer/PlatformDocAnalyzer.cs | 4 ---- eng/analyzers/README.md | 14 ++++++++++++++ .../src/System/Xml/Core/XmlReaderAsync.cs | 5 ++--- .../src/System/Xml/Core/XmlWriterAsync.cs | 6 ++---- .../Xml/Resolvers/XmlPreloadedResolverAsync.cs | 9 ++------- .../System/Xml/XmlResolver.FileSystemResolver.cs | 4 ++-- .../src/System/Xml/XmlResolver.ThrowingResolver.cs | 4 ++-- 7 files changed, 24 insertions(+), 22 deletions(-) create mode 100644 eng/analyzers/README.md diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs index 43c0010c7e722e..cc6fa64a5775b4 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs @@ -386,8 +386,6 @@ private static Location GetMemberIdentifierLocation(MemberDeclarationSyntax memb IndexerDeclarationSyntax i => i.ThisKeyword.GetLocation(), OperatorDeclarationSyntax o => o.OperatorToken.GetLocation(), ConversionOperatorDeclarationSyntax c => c.Type.GetLocation(), - TypeDeclarationSyntax t => t.Identifier.GetLocation(), - DelegateDeclarationSyntax d => d.Identifier.GetLocation(), EnumMemberDeclarationSyntax e => e.Identifier.GetLocation(), _ => member.GetLocation() }; @@ -407,8 +405,6 @@ private static string GetMemberName(MemberDeclarationSyntax member) IndexerDeclarationSyntax => "this[]", OperatorDeclarationSyntax o => "operator " + o.OperatorToken.Text, ConversionOperatorDeclarationSyntax c => c.ImplicitOrExplicitKeyword.Text + " operator " + c.Type, - TypeDeclarationSyntax t => t.Identifier.Text, - DelegateDeclarationSyntax d => d.Identifier.Text, EnumMemberDeclarationSyntax e => e.Identifier.Text, _ => "" }; diff --git a/eng/analyzers/README.md b/eng/analyzers/README.md new file mode 100644 index 00000000000000..067451c86c3818 --- /dev/null +++ b/eng/analyzers/README.md @@ -0,0 +1,14 @@ +# PlatformDocAnalyzer + +A Roslyn analyzer that enforces documentation placement conventions for platform-specific +libraries with `UseCompilerGeneratedDocXmlFile=true`. + +## Running tests + +``` +dotnet test eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj +``` + +These tests are not part of the main CI test pipeline (consistent with other infrastructure +analyzer tests like `IntrinsicsInSystemPrivateCoreLibAnalyzer.Tests`). Run them locally when +modifying the analyzer. diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs index 91fc8ad003d6ae..6c2239f902409f 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlReaderAsync.cs @@ -8,13 +8,12 @@ using System.Threading.Tasks; using System.Xml.Schema; -#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention - namespace System.Xml { - // Represents a reader that provides fast, non-cached forward only stream access to XML data. +#pragma warning disable PLATDOC002 // Async partial uses established TypeNameAsync.cs naming convention [DebuggerDisplay($"{{{nameof(DebuggerDisplayProxy)}}}")] public abstract partial class XmlReader : IDisposable +#pragma warning restore PLATDOC002 { public virtual Task GetValueAsync() { diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs index c163293903a93e..ab6cefb051647f 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Core/XmlWriterAsync.cs @@ -6,13 +6,11 @@ using System.Xml.Schema; using System.Xml.XPath; -#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention - namespace System.Xml { - // Represents a writer that provides fast non-cached forward-only way of generating XML streams containing XML documents - // that conform to the W3C Extensible Markup Language (XML) 1.0 specification and the Namespaces in XML specification. +#pragma warning disable PLATDOC002 // Async partial uses established TypeNameAsync.cs naming convention public abstract partial class XmlWriter : IDisposable, IAsyncDisposable +#pragma warning restore PLATDOC002 { // Write methods // Writes out the XML declaration with the version "1.0". diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs index a99eb19fbf97ac..c2b80bb6fdab01 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Resolvers/XmlPreloadedResolverAsync.cs @@ -5,16 +5,11 @@ using System.Threading.Tasks; using System.Xml; -#pragma warning disable PLATDOC002 // Not platform-specific; Async partial uses established naming convention - namespace System.Xml.Resolvers { - // - // XmlPreloadedResolver is an XmlResolver that which can be pre-loaded with data. - // By default it contains well-known DTDs for XHTML 1.0 and RSS 0.91. - // Custom mappings of URIs to data can be added with the Add method. - // +#pragma warning disable PLATDOC002 // Async partial uses established TypeNameAsync.cs naming convention public partial class XmlPreloadedResolver : XmlResolver +#pragma warning restore PLATDOC002 { public override Task GetEntityAsync(Uri absoluteUri, string? role, diff --git a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs index df97bb3963d640..e61e4082e7a4aa 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.FileSystemResolver.cs @@ -4,12 +4,11 @@ using System.IO; using System.Threading.Tasks; -#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention - namespace System.Xml { public abstract partial class XmlResolver { +#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention /// /// Gets an XML resolver which resolves only file system URIs. /// @@ -19,6 +18,7 @@ public abstract partial class XmlResolver /// instance returned by this property will resolve only URIs which scheme is file. /// public static XmlResolver FileSystemResolver => XmlFileSystemResolver.s_singleton; +#pragma warning restore PLATDOC003 // An XML resolver that resolves only file system URIs. private sealed class XmlFileSystemResolver : XmlResolver diff --git a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs index 09261ae7d17e7f..9fada155540a11 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/XmlResolver.ThrowingResolver.cs @@ -4,12 +4,11 @@ using System.Net; using System.Threading.Tasks; -#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention - namespace System.Xml { public abstract partial class XmlResolver { +#pragma warning disable PLATDOC003 // Not platform-specific; property lives in this file by convention /// /// Gets an XML resolver which forbids entity resolution. /// @@ -23,6 +22,7 @@ public abstract partial class XmlResolver /// prohibited, even when DTD processing is otherwise enabled. /// public static XmlResolver ThrowingResolver => XmlThrowingResolver.s_singleton; +#pragma warning restore PLATDOC003 // An XmlResolver that forbids all external entity resolution. private sealed class XmlThrowingResolver : XmlResolver From 0115a7cfa26cde06794422d59bcbae8e26e412fb Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Thu, 21 May 2026 11:21:32 -0700 Subject: [PATCH 7/8] Address round 2 PR feedback - Move PNSE Error check into GetCanonicalDocSourceTargetFramework where CanonicalDocSourceTargetFramework can actually be empty, making it reachable. - Skip nested INamedTypeSymbol members in AnalyzeDocConsistency to avoid duplicate PLATDOC004 reports (nested types get their own callback). - Cache s_memberRegex and s_whitespaceRegex as static compiled Regex instances to avoid repeated allocation on the hot path. - Remove unused Microsoft.CodeAnalysis.CSharp.CodeFix.Testing package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PlatformDocAnalyzer.Tests.csproj | 1 - .../PlatformDocAnalyzer/PlatformDocAnalyzer.cs | 11 +++++++++-- eng/intellisense.targets | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj index ef3f1388ac1a35..95e217d02037ec 100644 --- a/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj +++ b/eng/analyzers/PlatformDocAnalyzer.Tests/PlatformDocAnalyzer.Tests.csproj @@ -7,7 +7,6 @@ - diff --git a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs index cc6fa64a5775b4..9f4a1dac750bd0 100644 --- a/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs +++ b/eng/analyzers/PlatformDocAnalyzer/PlatformDocAnalyzer.cs @@ -26,6 +26,9 @@ namespace Microsoft.DotNet.Analyzers.PlatformDoc [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class PlatformDocAnalyzer : DiagnosticAnalyzer { + private static readonly Regex s_memberRegex = new(@"(.*?)", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex s_whitespaceRegex = new(@"\s+", RegexOptions.Compiled); + public const string DiagnosticIdMissingPrimaryFile = "PLATDOC001"; public const string DiagnosticIdBadPartialFileName = "PLATDOC002"; public const string DiagnosticIdDocsOnNonPrimaryFile = "PLATDOC003"; @@ -129,7 +132,7 @@ private static Dictionary ParseDocXml(string xml) // Use regex to extract member elements to avoid XML parser normalization // that could cause false mismatches with GetDocumentationCommentXml() output. - foreach (Match match in Regex.Matches(xml, @"(.*?)", RegexOptions.Singleline)) + foreach (Match match in s_memberRegex.Matches(xml)) { string name = match.Groups[1].Value; string innerXml = match.Groups[2].Value; @@ -163,6 +166,10 @@ private static void AnalyzeDocConsistency( if (member is IMethodSymbol { AssociatedSymbol: not null }) continue; + // Skip nested types; they get their own SymbolKind.NamedType callback. + if (member is INamedTypeSymbol) + continue; + CheckSymbolDoc(context, member, canonicalDocs); } } @@ -219,7 +226,7 @@ private static string StripMemberWrapper(string xml) private static string NormalizeDocXml(string xml) { // Normalize whitespace: collapse runs of whitespace into single spaces, trim. - return Regex.Replace(xml, @"\s+", " ").Trim(); + return s_whitespaceRegex.Replace(xml, " ").Trim(); } private static bool IsPlatformSpecificTfm(string tfm) diff --git a/eng/intellisense.targets b/eng/intellisense.targets index 64951904f906e7..382b498a35f3b2 100644 --- a/eng/intellisense.targets +++ b/eng/intellisense.targets @@ -129,6 +129,9 @@ @(_NonPlatformDocCandidate) @(_AnyDocCandidate) + + - + + <_StrippedTfm>$(TargetFramework.Substring(0, $(TargetFramework.IndexOf('-')))) + + + + + + + + - - @(_NonPlatformDocCandidate) + @(_StrippedTfmCandidate) + @(_NonPlatformDocCandidate) @(_AnyDocCandidate)