From 1f054371ad47e94685fe9653c8d5b994538b9a56 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 13:51:32 +1100 Subject: [PATCH 01/11] Add ktsu.Sdk.Analyzers project with initial analyzer implementation and metadata files --- Directory.Build.props | 113 ------------------ Sdk.Analyzers/AnalyzerReleases.Shipped.md | 3 + Sdk.Analyzers/AnalyzerReleases.Unshipped.md | 8 ++ Sdk.Analyzers/KtsuAnalyzerBase.cs | 15 +++ Sdk.Analyzers/Sdk.Analyzers.csproj | 39 ++++++ Sdk.Analyzers/UseStringLengthAnalyzer.cs | 89 ++++++++++++++ Sdk.App/Sdk.App.csproj | 11 ++ Sdk.Common.MetadataFiles.props | 62 ++++++++++ ...rgets => Sdk.Common.PackageContent.targets | 9 +- Sdk.Common.PackageProperties.props | 38 ++++++ Sdk.Common.SdkContent.targets | 10 ++ Sdk.Common.SolutionDiscovery.props | 30 +++++ Sdk.ConsoleApp/Sdk.ConsoleApp.csproj | 11 ++ Sdk.sln | 42 +++++++ Sdk/Sdk.csproj | 11 ++ Sdk/Sdk.targets | 5 + 16 files changed, 378 insertions(+), 118 deletions(-) delete mode 100644 Directory.Build.props create mode 100644 Sdk.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 Sdk.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 Sdk.Analyzers/KtsuAnalyzerBase.cs create mode 100644 Sdk.Analyzers/Sdk.Analyzers.csproj create mode 100644 Sdk.Analyzers/UseStringLengthAnalyzer.cs create mode 100644 Sdk.Common.MetadataFiles.props rename Directory.Build.targets => Sdk.Common.PackageContent.targets (88%) create mode 100644 Sdk.Common.PackageProperties.props create mode 100644 Sdk.Common.SdkContent.targets create mode 100644 Sdk.Common.SolutionDiscovery.props diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 7824e83..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,113 +0,0 @@ - - - - ktsu.$(MSBuildProjectName.Replace(" ", "")) - Library - - - dotnet-sdk.yml - copilot-instructions.md - $([MSBuild]::NormalizePath("$(SolutionDir)\.github")) - $([MSBuild]::NormalizePath("$(GitHubDir)\workflows")) - $([MSBuild]::NormalizePath("$(GitHubWorkflowDir)\$(GitHubWorkflowFileName)")) - $([MSBuild]::NormalizePath("$(GitHubDir)\$(GitHubCopilotInstructionsFileName)")) - - - .gitignore - .gitattributes - .gitconfig - .gitmodules - .mailmap - $([MSBuild]::NormalizePath("$(SolutionDir)\$(GitIgnoreFileName)")) - $([MSBuild]::NormalizePath("$(SolutionDir)\$(GitAttributesFileName)")) - $([MSBuild]::NormalizePath("$(SolutionDir)\$(GitConfigFileName)")) - $([MSBuild]::NormalizePath("$(SolutionDir)\$(GitModulesFileName)")) - $([MSBuild]::NormalizePath("$(SolutionDir)\$(GitMailMapFileName)")) - - - .editorconfig - .runsettings - $([MSBuild]::NormalizePath("$(SolutionDir)\$(EditorConfigFileName)")) - $([MSBuild]::NormalizePath("$(SolutionDir)\$(RunSettingsFileName)")) - - - $(SolutionName) - $(PrimaryProjectName).csproj - $([MSBuild]::NormalizePath("$(SolutionDir)\$(PrimaryProjectName)\$(PrimaryProjectFileName)")) - false - true - - - CONTRIBUTORS.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(ContributorsFileName)")) - $([System.IO.File]::ReadAllText($(ContributorsFilePath)).Trim()) - - CHANGELOG.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(ChangelogFileName)")) - $([System.IO.File]::ReadAllText($(ChangelogFilePath)).Trim()) - - README.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(ReadmeFileName)")) - $([System.IO.File]::ReadAllText($(ReadmeFilePath)).Trim()) - - LICENSE.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(LicenseFileName)")) - $([System.IO.File]::ReadAllText($(LicenseFilePath)).Trim()) - - AUTHORS.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(AuthorsFileName)")) - $([System.IO.File]::ReadAllText($(AuthorsFilePath)).Trim()) - - AUTHORS.url - $([MSBuild]::NormalizePath("$(SolutionDir)\$(AuthorsUrlFileName)")) - $([System.IO.File]::ReadAllText($(AuthorsUrlFilePath)).Trim()) - - PROJECT.url - $([MSBuild]::NormalizePath("$(SolutionDir)\$(ProjectUrlFileName)")) - $([System.IO.File]::ReadAllText($(ProjectUrlFilePath)).Trim()) - - COPYRIGHT.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(CopyrightFileName)")) - $([System.IO.File]::ReadAllText($(CopyrightFilePath)).Trim()) - - VERSION.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(VersionFileName)")) - $([System.IO.File]::ReadAllText($(VersionFilePath)).Trim()) - - DESCRIPTION.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(DescriptionFileName)")) - $([System.IO.File]::ReadAllText($(DescriptionFilePath)).Trim()) - - TAGS.md - $([MSBuild]::NormalizePath("$(SolutionDir)\$(TagsFileName)")) - $([System.IO.File]::ReadAllText($(TagsFilePath)).Trim()) - - icon.png - $([MSBuild]::NormalizePath("$(SolutionDir)\$(IconFileName)")) - - - $(AssemblyName) - $(Authors) - $(AssemblyName) - - MSBuildSdk - $(AssemblyName) - $(Version) - $(IconFileName) - $(ReadmeFileName) - $(LicenseFileName) - $(Changelog) - $(Description) - $(Tags) - $(ProjectUrl) - true - true - true - snupkg - true - - - false - true - - diff --git a/Sdk.Analyzers/AnalyzerReleases.Shipped.md b/Sdk.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..d027c51 --- /dev/null +++ b/Sdk.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/Sdk.Analyzers/AnalyzerReleases.Unshipped.md b/Sdk.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..bbd3ba9 --- /dev/null +++ b/Sdk.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +KTSU0001 | ktsu.Sdk | Info | Use string.Length instead of Count() diff --git a/Sdk.Analyzers/KtsuAnalyzerBase.cs b/Sdk.Analyzers/KtsuAnalyzerBase.cs new file mode 100644 index 0000000..d681fd6 --- /dev/null +++ b/Sdk.Analyzers/KtsuAnalyzerBase.cs @@ -0,0 +1,15 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ktsu.Sdk.Analyzers; + +/// +/// Base class for all ktsu.Sdk analyzers +/// +public abstract class KtsuAnalyzerBase : DiagnosticAnalyzer +{ + /// + /// Category for ktsu.Sdk analyzers + /// + protected const string Category = "ktsu.Sdk"; +} diff --git a/Sdk.Analyzers/Sdk.Analyzers.csproj b/Sdk.Analyzers/Sdk.Analyzers.csproj new file mode 100644 index 0000000..90bf927 --- /dev/null +++ b/Sdk.Analyzers/Sdk.Analyzers.csproj @@ -0,0 +1,39 @@ + + + + + + + + + netstandard2.0 + latest + enable + true + true + + + Analyzer + false + true + true + + + false + false + + + + + + + + + + + + + + + + diff --git a/Sdk.Analyzers/UseStringLengthAnalyzer.cs b/Sdk.Analyzers/UseStringLengthAnalyzer.cs new file mode 100644 index 0000000..9e27447 --- /dev/null +++ b/Sdk.Analyzers/UseStringLengthAnalyzer.cs @@ -0,0 +1,89 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ktsu.Sdk.Analyzers; + +/// +/// Analyzer that suggests using string.Length instead of string.Count() +/// This is a sample analyzer to demonstrate the analyzer infrastructure. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class UseStringLengthAnalyzer : KtsuAnalyzerBase +{ + /// + /// Diagnostic ID for this analyzer + /// + public const string DiagnosticId = "KTSU0001"; + + private static readonly LocalizableString Title = "Use string.Length instead of Count()"; + private static readonly LocalizableString MessageFormat = "Consider using '{0}.Length' instead of '{0}.Count()' for better performance"; + private static readonly LocalizableString Description = "Using the Length property is more efficient than calling the Count() extension method on strings."; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: Description); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a Count() method call + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return; + } + + if (memberAccess.Name.Identifier.ValueText != "Count") + { + return; + } + + // Check if the target is a string + var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess.Expression); + if (symbolInfo.Symbol is not ILocalSymbol and not IParameterSymbol and not IPropertySymbol and not IFieldSymbol) + { + return; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); + if (typeInfo.Type?.SpecialType != SpecialType.System_String) + { + return; + } + + // Check if this is the LINQ Count() extension method + var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (methodSymbol?.ContainingType.ToString() != "System.Linq.Enumerable") + { + return; + } + + // Report diagnostic + var diagnostic = Diagnostic.Create( + Rule, + invocation.GetLocation(), + memberAccess.Expression.ToString()); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/Sdk.App/Sdk.App.csproj b/Sdk.App/Sdk.App.csproj index 3463f22..f490d84 100644 --- a/Sdk.App/Sdk.App.csproj +++ b/Sdk.App/Sdk.App.csproj @@ -1,5 +1,16 @@ + + + + + net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.0;netstandard2.1 + + MSBuildSdk + + + + diff --git a/Sdk.Common.MetadataFiles.props b/Sdk.Common.MetadataFiles.props new file mode 100644 index 0000000..e76df61 --- /dev/null +++ b/Sdk.Common.MetadataFiles.props @@ -0,0 +1,62 @@ + + + + + AUTHORS.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(AuthorsFileName)")) + $([System.IO.File]::ReadAllText($(AuthorsFilePath)).Trim()) + + + VERSION.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(VersionFileName)")) + $([System.IO.File]::ReadAllText($(VersionFilePath)).Trim()) + + + DESCRIPTION.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(DescriptionFileName)")) + $([System.IO.File]::ReadAllText($(DescriptionFilePath)).Trim()) + + + TAGS.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(TagsFileName)")) + $([System.IO.File]::ReadAllText($(TagsFilePath)).Trim()) + + + LICENSE.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(LicenseFileName)")) + $([System.IO.File]::ReadAllText($(LicenseFilePath)).Trim()) + + + CHANGELOG.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(ChangelogFileName)")) + $([System.IO.File]::ReadAllText($(ChangelogFilePath)).Trim()) + + + README.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(ReadmeFileName)")) + $([System.IO.File]::ReadAllText($(ReadmeFilePath)).Trim()) + + + icon.png + $([MSBuild]::NormalizePath("$(SolutionDir)\$(IconFileName)")) + + + AUTHORS.url + $([MSBuild]::NormalizePath("$(SolutionDir)\$(AuthorsUrlFileName)")) + $([System.IO.File]::ReadAllText($(AuthorsUrlFilePath)).Trim()) + + + PROJECT.url + $([MSBuild]::NormalizePath("$(SolutionDir)\$(ProjectUrlFileName)")) + $([System.IO.File]::ReadAllText($(ProjectUrlFilePath)).Trim()) + + + COPYRIGHT.md + $([MSBuild]::NormalizePath("$(SolutionDir)\$(CopyrightFileName)")) + $([System.IO.File]::ReadAllText($(CopyrightFilePath)).Trim()) + + diff --git a/Directory.Build.targets b/Sdk.Common.PackageContent.targets similarity index 88% rename from Directory.Build.targets rename to Sdk.Common.PackageContent.targets index 775965d..417ddef 100644 --- a/Directory.Build.targets +++ b/Sdk.Common.PackageContent.targets @@ -1,10 +1,9 @@ + - - - - - diff --git a/Sdk.Common.PackageProperties.props b/Sdk.Common.PackageProperties.props new file mode 100644 index 0000000..9f919ae --- /dev/null +++ b/Sdk.Common.PackageProperties.props @@ -0,0 +1,38 @@ + + + + + ktsu.$(MSBuildProjectName.Replace(" ", "")) + Library + + + $(AssemblyName) + $(Authors) + $(AssemblyName) + $(AssemblyName) + $(Version) + + + $(IconFileName) + $(ReadmeFileName) + $(LicenseFileName) + $(Changelog) + $(Description) + $(Tags) + $(ProjectUrl) + + + true + true + true + snupkg + true + + + false + true + + diff --git a/Sdk.Common.SdkContent.targets b/Sdk.Common.SdkContent.targets new file mode 100644 index 0000000..99de19e --- /dev/null +++ b/Sdk.Common.SdkContent.targets @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Sdk.Common.SolutionDiscovery.props b/Sdk.Common.SolutionDiscovery.props new file mode 100644 index 0000000..db9d7cb --- /dev/null +++ b/Sdk.Common.SolutionDiscovery.props @@ -0,0 +1,30 @@ + + + + + <_SolutionFileExists_Level1 Condition="'$(SolutionDir)' == ''">$([System.IO.Directory]::GetFiles('$(MSBuildProjectDirectory)', '*.sln').Length) + $(MSBuildProjectDirectory) + + + <_SolutionFileExists_Level2 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..'))','*.sln').Length) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..')) + + + <_SolutionFileExists_Level3 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))','*.sln').Length) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..')) + + + <_SolutionFileExists_Level4 Condition="'$(SolutionDir)' == '' And Exists('\')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..'))','*.sln').Length) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..') ) + + + <_SolutionFileExists_Level5 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..\..\..\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\..\'))','*.sln').Length) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\..') ) + + + $(MSBuildProjectDirectory) + + diff --git a/Sdk.ConsoleApp/Sdk.ConsoleApp.csproj b/Sdk.ConsoleApp/Sdk.ConsoleApp.csproj index 3463f22..f490d84 100644 --- a/Sdk.ConsoleApp/Sdk.ConsoleApp.csproj +++ b/Sdk.ConsoleApp/Sdk.ConsoleApp.csproj @@ -1,5 +1,16 @@ + + + + + net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.0;netstandard2.1 + + MSBuildSdk + + + + diff --git a/Sdk.sln b/Sdk.sln index cded009..baf4c7d 100644 --- a/Sdk.sln +++ b/Sdk.sln @@ -9,24 +9,66 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk.App", "Sdk.App\Sdk.App. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk.ConsoleApp", "Sdk.ConsoleApp\Sdk.ConsoleApp.csproj", "{E33FCF20-FACE-A5BF-E0B7-399C6465B204}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sdk.Analyzers", "Sdk.Analyzers\Sdk.Analyzers.csproj", "{E7C6FABE-42DE-4DB6-8CDA-5527789D0538}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|x64.Build.0 = Debug|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Debug|x86.Build.0 = Debug|Any CPU {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|Any CPU.Build.0 = Release|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|x64.ActiveCfg = Release|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|x64.Build.0 = Release|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|x86.ActiveCfg = Release|Any CPU + {49EC93AA-B8C7-4C2F-8E38-D9A7F01383CA}.Release|x86.Build.0 = Release|Any CPU {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|x64.Build.0 = Debug|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Debug|x86.Build.0 = Debug|Any CPU {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|Any CPU.Build.0 = Release|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|x64.ActiveCfg = Release|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|x64.Build.0 = Release|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|x86.ActiveCfg = Release|Any CPU + {D0BA1D34-ED6C-AEC5-4ABA-D13185D9E53E}.Release|x86.Build.0 = Release|Any CPU {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|x64.ActiveCfg = Debug|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|x64.Build.0 = Debug|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|x86.ActiveCfg = Debug|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Debug|x86.Build.0 = Debug|Any CPU {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|Any CPU.ActiveCfg = Release|Any CPU {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|Any CPU.Build.0 = Release|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|x64.ActiveCfg = Release|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|x64.Build.0 = Release|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|x86.ActiveCfg = Release|Any CPU + {E33FCF20-FACE-A5BF-E0B7-399C6465B204}.Release|x86.Build.0 = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|x64.Build.0 = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Debug|x86.Build.0 = Debug|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|Any CPU.Build.0 = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|x64.ActiveCfg = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|x64.Build.0 = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|x86.ActiveCfg = Release|Any CPU + {E7C6FABE-42DE-4DB6-8CDA-5527789D0538}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Sdk/Sdk.csproj b/Sdk/Sdk.csproj index b2874e9..9119096 100644 --- a/Sdk/Sdk.csproj +++ b/Sdk/Sdk.csproj @@ -1,5 +1,16 @@  + + + + + net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.0;netstandard2.1 + + MSBuildSdk + + + + diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index 8f8bd3b..b894fb9 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -38,6 +38,11 @@ + + + all + + all From 4ca45a2841e599c426e21b604a093533a0efe37c Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 15:23:37 +1100 Subject: [PATCH 02/11] [major] Add Roslyn analyzers to enforce SDK requirements and remove UseStringLengthAnalyzer This release introduces the ktsu.Sdk.Analyzers package with two new error-level analyzers that enforce SDK requirements, and removes the UseStringLengthAnalyzer. **KTSU0001: Missing required package reference (Error)** - Enforces that projects include required standard packages based on target framework - Required packages include: - Microsoft.SourceLink.GitHub (all frameworks) - Polyfill (net8.0, net7.0, net6.0, net5.0, netstandard2.0, netstandard2.1) - System.Memory (netstandard2.0, netstandard2.1) - System.Threading.Tasks.Extensions (netstandard2.0, netstandard2.1) - Test projects are exempt from all requirements except SourceLink - Provides clear error messages with exact PackageReference XML to add **KTSU0002: Missing InternalsVisibleTo attribute for test project (Error)** - Enforces that non-test projects expose internals to their test projects - Only triggers when a corresponding test project exists - Includes automatic code fixer to add the InternalsVisibleTo attribute - Enables comprehensive testing of internal members ## Breaking Changes Projects using ktsu.Sdk will now encounter build errors if they: 1. Do not reference required standard packages for their target framework 2. Have a test project but do not expose internals via InternalsVisibleToAttribute --- .github/workflows/dotnet-sdk.yml | 5 + CLAUDE.md | 9 +- README.md | 21 ++- ...ernalsVisibleToAttributeCodeFixProvider.cs | 113 ++++++++++++++ Sdk.Analyzers/AnalyzerReleases.Shipped.md | 9 ++ Sdk.Analyzers/AnalyzerReleases.Unshipped.md | 1 - Sdk.Analyzers/KtsuAnalyzerBase.cs | 8 +- ...singInternalsVisibleToAttributeAnalyzer.cs | 109 +++++++++++++ .../MissingStandardPackagesAnalyzer.cs | 143 ++++++++++++++++++ Sdk.Analyzers/Sdk.Analyzers.csproj | 4 + Sdk.Analyzers/UseStringLengthAnalyzer.cs | 89 ----------- Sdk/Sdk.targets | 48 +----- scripts/commit-metadata.ps1 | 8 + scripts/make-analyzer-releases.ps1 | 28 ++++ 14 files changed, 454 insertions(+), 141 deletions(-) create mode 100644 Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs create mode 100644 Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs create mode 100644 Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs delete mode 100644 Sdk.Analyzers/UseStringLengthAnalyzer.cs create mode 100644 scripts/make-analyzer-releases.ps1 diff --git a/.github/workflows/dotnet-sdk.yml b/.github/workflows/dotnet-sdk.yml index cbe7c7c..1d8dfc8 100644 --- a/.github/workflows/dotnet-sdk.yml +++ b/.github/workflows/dotnet-sdk.yml @@ -96,6 +96,11 @@ jobs: shell: pwsh run: scripts/make-changelog.ps1 "${{ env.VERSION }}" "${{ github.sha }}" + - name: Update Analyzer Releases + if: ${{ env.SHOULD_RELEASE == 'True' }} + shell: pwsh + run: scripts/make-analyzer-releases.ps1 + - name: Commit Metadata if: ${{ env.SHOULD_RELEASE == 'True' }} shell: pwsh diff --git a/CLAUDE.md b/CLAUDE.md index 57ee85e..e452d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,9 +87,14 @@ The SDK automatically detects project types based on naming conventions: Properties set based on detection: `IsPrimaryProject`, `IsCliProject`, `IsAppProject`, `IsTestProject` -### Automatic Project References +### Analyzer-Enforced Requirements -Non-primary projects automatically reference the primary project if it exists. Test projects automatically get `InternalsVisibleTo` access from the projects they test. +The SDK uses Roslyn analyzers to enforce proper project configuration: + +- **KTSU0001 (Error)**: Projects must include required standard packages (SourceLink, Polyfill, System.Memory, System.Threading.Tasks.Extensions). Requirements vary based on project type and target framework. +- **KTSU0002 (Error)**: Projects must expose internals to test projects using `[assembly: InternalsVisibleTo(...)]`. A code fixer is available to automatically add this attribute. + +These properties are passed to analyzers via `CompilerVisibleProperty`: `IsTestProject`, `TestProjectExists`, `TestProjectNamespace`, `TargetFramework`, `TargetFrameworkIdentifier`. ### Metadata File Integration diff --git a/README.md b/README.md index 5332b31..c2f2ced 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,8 @@ For a GUI application: ### 🔧 **Development Workflow** -- **Automatic Project References**: Smart cross-project referencing based on project types -- **Internals Visibility**: Automatic InternalsVisibleTo configuration for test projects +- **Analyzer-Enforced Requirements**: Roslyn analyzers (KTSU0001/KTSU0002) ensure proper package dependencies and internals visibility with helpful diagnostics and code fixers +- **Internals Visibility**: Code fixer to easily add InternalsVisibleTo attributes for test projects - **GitHub Integration**: Built-in support for GitHub workflows and CI/CD - **Cross-Platform Support**: Compatible with Windows, macOS, and Linux - **Documentation Generation**: Automated XML documentation file generation @@ -229,14 +229,21 @@ This enables the SDK to work with any nested project structure without configura ## Advanced Configuration Features -### Automatic Project References +### Analyzer-Enforced Requirements -Projects automatically reference the primary project and expose internals to test projects. Cross-references are intelligently configured based on project types and naming conventions. +The SDK includes Roslyn analyzers that enforce proper project configuration with helpful diagnostics and code fixers: -For example: +**KTSU0001 (Error)**: Projects must include required standard packages +- Enforces SourceLink packages (GitHub, Azure Repos) +- Enforces Polyfill package for non-test projects +- Enforces compatibility packages (System.Memory, System.Threading.Tasks.Extensions) based on target framework +- Diagnostic message includes package name and version number -- Non-primary projects automatically get `` to the primary project -- Primary project automatically exposes internals to test projects via `` +**KTSU0002 (Error)**: Projects must expose internals to test projects +- Code fixer automatically adds `[assembly: InternalsVisibleTo(...)]` attribute +- Use Ctrl+. (Quick Actions) to apply the fix + +These analyzers ensure consistent project structure while giving you explicit control over dependencies. ### Available Properties diff --git a/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs b/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs new file mode 100644 index 0000000..5526cd1 --- /dev/null +++ b/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Sdk.Analyzers; + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// Code fix provider that adds InternalsVisibleToAttribute to expose internals to test projects +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddInternalsVisibleToAttributeCodeFixProvider))] +[Shared] +public class AddInternalsVisibleToAttributeCodeFixProvider : CodeFixProvider +{ + + /// + public override ImmutableArray FixableDiagnosticIds => [MissingInternalsVisibleToAttributeAnalyzer.DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + Diagnostic diagnostic = context.Diagnostics.First(); + + context.RegisterCodeFix( + CodeAction.Create( + title: "Add [assembly: InternalsVisibleTo(...)]", + createChangedDocument: ct => AddInternalsVisibleToAttributeAsync(context.Document, diagnostic, ct), + equivalenceKey: nameof(AddInternalsVisibleToAttributeCodeFixProvider)), + diagnostic); + } + + private static async Task AddInternalsVisibleToAttributeAsync( + Document document, + Diagnostic diagnostic, + CancellationToken cancellationToken) + { + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is not CompilationUnitSyntax compilationUnit) + { + return document; + } + + // Get test project namespace from analyzer config options + + AnalyzerConfigOptions options = document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions; + if (!options.TryGetValue("build_property.TestProjectNamespace", out string? testNamespace) || string.IsNullOrWhiteSpace(testNamespace)) + { + return document; + } + + // Check if using directive already exists + + bool hasUsing = compilationUnit.Usings.Any(u => + u.Name?.ToString() == "System.Runtime.CompilerServices"); + + // Create the using directive if needed + + SyntaxList newUsings = compilationUnit.Usings; + if (!hasUsing) + { + UsingDirectiveSyntax usingDirective = SyntaxFactory.UsingDirective( + SyntaxFactory.ParseName("System.Runtime.CompilerServices")) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + newUsings = newUsings.Add(usingDirective); + } + + // Create the InternalsVisibleTo attribute + + AttributeArgumentSyntax attributeArgument = SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(testNamespace))); + + AttributeSyntax attribute = SyntaxFactory.Attribute( + SyntaxFactory.ParseName("assembly: System.Runtime.CompilerServices.InternalsVisibleTo"), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList(attributeArgument))); + + AttributeListSyntax attributeList = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList(attribute)) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + + // Add the attribute to the compilation unit + + CompilationUnitSyntax newCompilationUnit = compilationUnit + .WithUsings(newUsings) + .AddAttributeLists(attributeList) + .WithLeadingTrivia(compilationUnit.GetLeadingTrivia()) + .WithTrailingTrivia(compilationUnit.GetTrailingTrivia()); + + return document.WithSyntaxRoot(newCompilationUnit); + } +} diff --git a/Sdk.Analyzers/AnalyzerReleases.Shipped.md b/Sdk.Analyzers/AnalyzerReleases.Shipped.md index d027c51..509f7d0 100644 --- a/Sdk.Analyzers/AnalyzerReleases.Shipped.md +++ b/Sdk.Analyzers/AnalyzerReleases.Shipped.md @@ -1,3 +1,12 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +## Release {version} + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +KTSU0001 | ktsu.Sdk | Error | Missing required package reference +KTSU0002 | ktsu.Sdk | Error | Missing InternalsVisibleTo attribute for test project + diff --git a/Sdk.Analyzers/AnalyzerReleases.Unshipped.md b/Sdk.Analyzers/AnalyzerReleases.Unshipped.md index bbd3ba9..e3690dd 100644 --- a/Sdk.Analyzers/AnalyzerReleases.Unshipped.md +++ b/Sdk.Analyzers/AnalyzerReleases.Unshipped.md @@ -5,4 +5,3 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- -KTSU0001 | ktsu.Sdk | Info | Use string.Length instead of Count() diff --git a/Sdk.Analyzers/KtsuAnalyzerBase.cs b/Sdk.Analyzers/KtsuAnalyzerBase.cs index d681fd6..87404cb 100644 --- a/Sdk.Analyzers/KtsuAnalyzerBase.cs +++ b/Sdk.Analyzers/KtsuAnalyzerBase.cs @@ -1,13 +1,17 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. namespace ktsu.Sdk.Analyzers; +using Microsoft.CodeAnalysis.Diagnostics; + /// /// Base class for all ktsu.Sdk analyzers /// public abstract class KtsuAnalyzerBase : DiagnosticAnalyzer { + /// /// Category for ktsu.Sdk analyzers /// diff --git a/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs new file mode 100644 index 0000000..9639d3a --- /dev/null +++ b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs @@ -0,0 +1,109 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Sdk.Analyzers; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// Analyzer that suggests exposing internals to test projects via InternalsVisibleToAttribute +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MissingInternalsVisibleToAttributeAnalyzer : KtsuAnalyzerBase +{ + + /// + /// Diagnostic ID for this analyzer + /// + public const string DiagnosticId = "KTSU0002"; + + private static readonly LocalizableString Title = "Missing InternalsVisibleTo attribute for test project"; + private static readonly LocalizableString MessageFormat = "Consider exposing internals to test project '{0}'. Add '[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(\"{0}\")]' to a .cs file."; + private static readonly LocalizableString Description = "Projects should expose their internal members to test projects using the InternalsVisibleToAttribute for comprehensive testing."; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Description, + customTags: "CompilationEnd"); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + private static void AnalyzeCompilation(CompilationAnalysisContext context) + { + AnalyzerConfigOptions options = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + + // Check if this is a non-test project + + if (!options.TryGetValue("build_property.IsTestProject", out string? isTest) || isTest != "false") + { + return; + } + + // Check if a test project exists + + if (!options.TryGetValue("build_property.TestProjectExists", out string? exists) || exists != "true") + { + return; + } + + // Get test project namespace + + if (!options.TryGetValue("build_property.TestProjectNamespace", out string? testNamespace) || string.IsNullOrWhiteSpace(testNamespace)) + { + return; + } + + // Check if InternalsVisibleToAttribute already exists for the test project + + IEnumerable internalsVisibleToAttributes = context.Compilation.Assembly + .GetAttributes() + .Where(attr => attr.AttributeClass?.Name == "InternalsVisibleToAttribute" && + attr.AttributeClass.ContainingNamespace?.ToDisplayString() == "System.Runtime.CompilerServices"); + + bool hasTestReference = internalsVisibleToAttributes.Any(attr => + { + if (attr.ConstructorArguments.Length > 0) + { + TypedConstant firstArg = attr.ConstructorArguments[0]; + if (firstArg.Kind == TypedConstantKind.Primitive && firstArg.Value is string assemblyName) + { + return assemblyName == testNamespace; + } + } + return false; + }); + + if (!hasTestReference) + { + // Report diagnostic at the first syntax tree location (project-level diagnostic) + + Location location = context.Compilation.SyntaxTrees.FirstOrDefault()?.GetRoot().GetLocation() ?? Location.None; + + Diagnostic diagnostic = Diagnostic.Create( + Rule, + location, + testNamespace); + + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs new file mode 100644 index 0000000..532fe2e --- /dev/null +++ b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs @@ -0,0 +1,143 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Sdk.Analyzers; + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// Analyzer that enforces required standard package references +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MissingStandardPackagesAnalyzer : KtsuAnalyzerBase +{ + + /// + /// Diagnostic ID for this analyzer + /// + public const string DiagnosticId = "KTSU0001"; + + private static readonly LocalizableString Title = "Missing required package reference"; + private static readonly LocalizableString MessageFormat = "Project must reference package '{0}'. Add '' to your .csproj file."; + private static readonly LocalizableString Description = "Projects should include required standard packages for SourceLink, polyfills, and framework compatibility."; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Description, + customTags: "CompilationEnd"); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + private static void AnalyzeCompilation(CompilationAnalysisContext context) + { + AnalyzerConfigOptions options = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + + // Get project properties + + options.TryGetValue("build_property.IsTestProject", out string? isTestProject); + options.TryGetValue("build_property.TargetFramework", out string? targetFramework); + options.TryGetValue("build_property.TargetFrameworkIdentifier", out string? targetFrameworkIdentifier); + + // Check for Microsoft.SourceLink.GitHub + + CheckPackage(context, "Microsoft.SourceLink.GitHub"); + + // Check for Microsoft.SourceLink.AzureRepos.Git + + CheckPackage(context, "Microsoft.SourceLink.AzureRepos.Git"); + + // Check for Polyfill (non-test projects only) + + if (isTestProject != "true") + { + CheckPackage(context, "Polyfill"); + } + + // Check for System.Memory (conditional on target framework) + + if (RequiresSystemMemory(targetFramework, targetFrameworkIdentifier)) + { + CheckPackage(context, "System.Memory"); + } + + // Check for System.Threading.Tasks.Extensions (conditional on target framework) + + if (RequiresTaskExtensions(targetFramework, targetFrameworkIdentifier)) + { + CheckPackage(context, "System.Threading.Tasks.Extensions"); + } + } + + private static void CheckPackage(CompilationAnalysisContext context, string packageName) + { + // Check if package reference exists in compilation + // We look for the main assembly name from the package + + string assemblyName = packageName; + + bool hasReference = context.Compilation.References + .Any(r => r.Display?.Contains(assemblyName) == true); + + if (!hasReference) + { + Location location = context.Compilation.SyntaxTrees.FirstOrDefault()?.GetRoot().GetLocation() ?? Location.None; + + Diagnostic diagnostic = Diagnostic.Create( + Rule, + location, + packageName); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool RequiresSystemMemory(string? targetFramework, string? targetFrameworkIdentifier) + { + if (string.IsNullOrEmpty(targetFramework) || string.IsNullOrEmpty(targetFrameworkIdentifier)) + { + return false; + } + + // Condition: $(TargetFrameworkIdentifier) == '.NETStandard' or + // $(TargetFrameworkIdentifier) == '.NETFramework' or + // $(TargetFramework.StartsWith('netcoreapp2')) + + return targetFrameworkIdentifier == ".NETStandard" || + targetFrameworkIdentifier == ".NETFramework" || + targetFramework!.StartsWith("netcoreapp2", System.StringComparison.Ordinal); + } + + private static bool RequiresTaskExtensions(string? targetFramework, string? targetFrameworkIdentifier) + { + if (string.IsNullOrEmpty(targetFramework)) + { + return false; + } + + // Condition: $(TargetFramework) == 'netstandard2.0' or + // $(TargetFramework) == 'netcoreapp2.0' or + // $(TargetFrameworkIdentifier) == '.NETFramework' + + return targetFramework == "netstandard2.0" || + targetFramework == "netcoreapp2.0" || + targetFrameworkIdentifier == ".NETFramework"; + } +} diff --git a/Sdk.Analyzers/Sdk.Analyzers.csproj b/Sdk.Analyzers/Sdk.Analyzers.csproj index 90bf927..1994c54 100644 --- a/Sdk.Analyzers/Sdk.Analyzers.csproj +++ b/Sdk.Analyzers/Sdk.Analyzers.csproj @@ -12,6 +12,9 @@ true true + + $(NoWarn);RS1038 + Analyzer false @@ -26,6 +29,7 @@ + diff --git a/Sdk.Analyzers/UseStringLengthAnalyzer.cs b/Sdk.Analyzers/UseStringLengthAnalyzer.cs deleted file mode 100644 index 9e27447..0000000 --- a/Sdk.Analyzers/UseStringLengthAnalyzer.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Immutable; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace ktsu.Sdk.Analyzers; - -/// -/// Analyzer that suggests using string.Length instead of string.Count() -/// This is a sample analyzer to demonstrate the analyzer infrastructure. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class UseStringLengthAnalyzer : KtsuAnalyzerBase -{ - /// - /// Diagnostic ID for this analyzer - /// - public const string DiagnosticId = "KTSU0001"; - - private static readonly LocalizableString Title = "Use string.Length instead of Count()"; - private static readonly LocalizableString MessageFormat = "Consider using '{0}.Length' instead of '{0}.Count()' for better performance"; - private static readonly LocalizableString Description = "Using the Length property is more efficient than calling the Count() extension method on strings."; - - private static readonly DiagnosticDescriptor Rule = new( - DiagnosticId, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Info, - isEnabledByDefault: true, - description: Description); - - /// - public override ImmutableArray SupportedDiagnostics => [Rule]; - - /// - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); - } - - private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) - { - var invocation = (InvocationExpressionSyntax)context.Node; - - // Check if this is a Count() method call - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) - { - return; - } - - if (memberAccess.Name.Identifier.ValueText != "Count") - { - return; - } - - // Check if the target is a string - var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess.Expression); - if (symbolInfo.Symbol is not ILocalSymbol and not IParameterSymbol and not IPropertySymbol and not IFieldSymbol) - { - return; - } - - var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); - if (typeInfo.Type?.SpecialType != SpecialType.System_String) - { - return; - } - - // Check if this is the LINQ Count() extension method - var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; - if (methodSymbol?.ContainingType.ToString() != "System.Linq.Enumerable") - { - return; - } - - // Report diagnostic - var diagnostic = Diagnostic.Create( - Rule, - invocation.GetLocation(), - memberAccess.Expression.ToString()); - - context.ReportDiagnostic(diagnostic); - } -} diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index b894fb9..0d2d42f 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -25,54 +25,22 @@ - - - - - - - - + + + + + + + - + - - all - - - all - - - - all - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - true - true - diff --git a/scripts/commit-metadata.ps1 b/scripts/commit-metadata.ps1 index 6de2491..25538df 100644 --- a/scripts/commit-metadata.ps1 +++ b/scripts/commit-metadata.ps1 @@ -1,6 +1,14 @@ git config --global user.name "Github Actions" git config --global user.email "actions@users.noreply.github.com" + +# Add standard metadata files git add VERSION.md LICENSE.md AUTHORS.md COPYRIGHT.md CHANGELOG.md PROJECT_URL.url AUTHORS.url + +# Add analyzer releases file if it exists +if (Test-Path "Sdk.Analyzers/AnalyzerReleases.Shipped.md") { + git add Sdk.Analyzers/AnalyzerReleases.Shipped.md +} + git commit -m "[bot][skip ci] Update Metadata" git push diff --git a/scripts/make-analyzer-releases.ps1 b/scripts/make-analyzer-releases.ps1 new file mode 100644 index 0000000..48d806d --- /dev/null +++ b/scripts/make-analyzer-releases.ps1 @@ -0,0 +1,28 @@ +Set-PSDebug -Trace 1 + +# Read the version from VERSION.md +if (Test-Path VERSION.md) { + $VERSION = (Get-Content VERSION.md -Raw).Trim() + Write-Host "VERSION: $VERSION" +} else { + Write-Host "VERSION.md not found, skipping analyzer releases update" + exit 0 +} + +# Update AnalyzerReleases.Shipped.md if it exists and contains the placeholder +$SHIPPED_FILE = "Sdk.Analyzers/AnalyzerReleases.Shipped.md" +if (Test-Path $SHIPPED_FILE) { + $CONTENT = Get-Content $SHIPPED_FILE -Raw + if ($CONTENT -match '\{version\}') { + Write-Host "Updating $SHIPPED_FILE with version $VERSION" + $UPDATED_CONTENT = $CONTENT -replace '\{version\}', $VERSION + $UPDATED_CONTENT | Out-File -FilePath $SHIPPED_FILE -Encoding utf8 -NoNewline + Write-Host "Updated $SHIPPED_FILE" + } else { + Write-Host "No {version} placeholder found in $SHIPPED_FILE, skipping update" + } +} else { + Write-Host "$SHIPPED_FILE not found, skipping analyzer releases update" +} + +$global:LASTEXITCODE = 0 From 6490f66194dfec43d7f079cecb1909fe3b14e84e Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 15:36:00 +1100 Subject: [PATCH 03/11] Fix solution directory discovery logic for great-grandparent level --- Sdk.Common.SolutionDiscovery.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sdk.Common.SolutionDiscovery.props b/Sdk.Common.SolutionDiscovery.props index db9d7cb..085f908 100644 --- a/Sdk.Common.SolutionDiscovery.props +++ b/Sdk.Common.SolutionDiscovery.props @@ -17,7 +17,7 @@ $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..')) - <_SolutionFileExists_Level4 Condition="'$(SolutionDir)' == '' And Exists('\')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..'))','*.sln').Length) + <_SolutionFileExists_Level4 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..\..\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..'))','*.sln').Length) $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..') ) From 75aa17b81c5a5ce0778070b57c9a546b23e30abc Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 15:40:52 +1100 Subject: [PATCH 04/11] Enhance MissingStandardPackagesAnalyzer to validate additional package references via MSBuild properties --- .../MissingStandardPackagesAnalyzer.cs | 38 +++++++++++-------- Sdk/Sdk.targets | 32 ++++++++++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs index 532fe2e..912c750 100644 --- a/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs +++ b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs @@ -56,47 +56,53 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) options.TryGetValue("build_property.TargetFramework", out string? targetFramework); options.TryGetValue("build_property.TargetFrameworkIdentifier", out string? targetFrameworkIdentifier); - // Check for Microsoft.SourceLink.GitHub + // Get package reference properties (passed from MSBuild) - CheckPackage(context, "Microsoft.SourceLink.GitHub"); + options.TryGetValue("build_property.HasSourceLinkGitHub", out string? hasSourceLinkGitHub); + options.TryGetValue("build_property.HasSourceLinkAzureRepos", out string? hasSourceLinkAzureRepos); + options.TryGetValue("build_property.HasPolyfill", out string? hasPolyfill); + options.TryGetValue("build_property.HasSystemMemory", out string? hasSystemMemory); + options.TryGetValue("build_property.HasSystemThreadingTasksExtensions", out string? hasSystemThreadingTasksExtensions); - // Check for Microsoft.SourceLink.AzureRepos.Git + // Check for Microsoft.SourceLink.GitHub (build-time-only package) - CheckPackage(context, "Microsoft.SourceLink.AzureRepos.Git"); + CheckPackageProperty(context, "Microsoft.SourceLink.GitHub", hasSourceLinkGitHub); - // Check for Polyfill (non-test projects only) + // Check for Microsoft.SourceLink.AzureRepos.Git (build-time-only package) + + CheckPackageProperty(context, "Microsoft.SourceLink.AzureRepos.Git", hasSourceLinkAzureRepos); + + // Check for Polyfill (build-time-only package, non-test projects only) if (isTestProject != "true") { - CheckPackage(context, "Polyfill"); + CheckPackageProperty(context, "Polyfill", hasPolyfill); } // Check for System.Memory (conditional on target framework) if (RequiresSystemMemory(targetFramework, targetFrameworkIdentifier)) { - CheckPackage(context, "System.Memory"); + CheckPackageProperty(context, "System.Memory", hasSystemMemory); } // Check for System.Threading.Tasks.Extensions (conditional on target framework) if (RequiresTaskExtensions(targetFramework, targetFrameworkIdentifier)) { - CheckPackage(context, "System.Threading.Tasks.Extensions"); + CheckPackageProperty(context, "System.Threading.Tasks.Extensions", hasSystemThreadingTasksExtensions); } } - private static void CheckPackage(CompilationAnalysisContext context, string packageName) + private static void CheckPackageProperty(CompilationAnalysisContext context, string packageName, string? hasPackageProperty) { - // Check if package reference exists in compilation - // We look for the main assembly name from the package - - string assemblyName = packageName; + // Check if package reference exists via MSBuild property + // Build-time-only packages (with PrivateAssets=all) don't appear in compilation references, + // so we need to check MSBuild properties passed via CompilerVisibleProperty - bool hasReference = context.Compilation.References - .Any(r => r.Display?.Contains(assemblyName) == true); + bool hasPackage = hasPackageProperty == "true"; - if (!hasReference) + if (!hasPackage) { Location location = context.Compilation.SyntaxTrees.FirstOrDefault()?.GetRoot().GetLocation() ?? Location.None; diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index 0d2d42f..0c59866 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -25,6 +25,33 @@ + + + + <_SourceLinkGitHub Include="@(PackageReference)" Condition="'%(Identity)' == 'Microsoft.SourceLink.GitHub'" /> + <_SourceLinkAzureRepos Include="@(PackageReference)" Condition="'%(Identity)' == 'Microsoft.SourceLink.AzureRepos.Git'" /> + <_Polyfill Include="@(PackageReference)" Condition="'%(Identity)' == 'Polyfill'" /> + <_SystemMemory Include="@(PackageReference)" Condition="'%(Identity)' == 'System.Memory'" /> + <_SystemThreadingTasksExtensions Include="@(PackageReference)" Condition="'%(Identity)' == 'System.Threading.Tasks.Extensions'" /> + + + true + false + + true + false + + true + false + + true + false + + true + false + + + @@ -32,6 +59,11 @@ + + + + + From 32c7b175b896a7f81b32d70ea13fb59e0f769c30 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 15:42:48 +1100 Subject: [PATCH 05/11] Fix attribute syntax for InternalsVisibleTo in code fix provider --- Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs b/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs index 5526cd1..c447cf1 100644 --- a/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs +++ b/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs @@ -92,11 +92,12 @@ private static async Task AddInternalsVisibleToAttributeAsync( SyntaxFactory.Literal(testNamespace))); AttributeSyntax attribute = SyntaxFactory.Attribute( - SyntaxFactory.ParseName("assembly: System.Runtime.CompilerServices.InternalsVisibleTo"), + SyntaxFactory.ParseName("System.Runtime.CompilerServices.InternalsVisibleTo"), SyntaxFactory.AttributeArgumentList( SyntaxFactory.SingletonSeparatedList(attributeArgument))); AttributeListSyntax attributeList = SyntaxFactory.AttributeList( + SyntaxFactory.AttributeTargetSpecifier(SyntaxFactory.Token(SyntaxKind.AssemblyKeyword)), SyntaxFactory.SingletonSeparatedList(attribute)) .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); From b6da61b40b3779d1bfc48cb0f80a55281a79d288 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 15:50:34 +1100 Subject: [PATCH 06/11] Update Sdk.targets to replace {version} placeholder with actual SDK version in release script --- Sdk/Sdk.targets | 2 +- scripts/make-analyzer-releases.ps1 | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index 0c59866..457f623 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -68,7 +68,7 @@ - + all diff --git a/scripts/make-analyzer-releases.ps1 b/scripts/make-analyzer-releases.ps1 index 48d806d..bac1b51 100644 --- a/scripts/make-analyzer-releases.ps1 +++ b/scripts/make-analyzer-releases.ps1 @@ -25,4 +25,18 @@ if (Test-Path $SHIPPED_FILE) { Write-Host "$SHIPPED_FILE not found, skipping analyzer releases update" } +# Update Sdk.targets files to replace {version} placeholder with actual SDK version +Get-ChildItem -Recurse -Filter "Sdk.targets" | ForEach-Object { + $TARGET_FILE = $_.FullName + $CONTENT = Get-Content $TARGET_FILE -Raw + if ($CONTENT -match '\{version\}') { + Write-Host "Updating $TARGET_FILE with version $VERSION" + $UPDATED_CONTENT = $CONTENT -replace '\{version\}', $VERSION + $UPDATED_CONTENT | Out-File -FilePath $TARGET_FILE -Encoding utf8 -NoNewline + Write-Host "Updated $TARGET_FILE" + } else { + Write-Host "No {version} placeholder found in $TARGET_FILE, skipping update" + } +} + $global:LASTEXITCODE = 0 From 5a4391eecceaea550d1070425cd9b57b6762cc90 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 16:05:54 +1100 Subject: [PATCH 07/11] Update SetPackageReferenceProperties target to run before GenerateMSBuildEditorConfigFile --- Sdk/Sdk.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index 457f623..8b3921d 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -26,7 +26,7 @@ - + <_SourceLinkGitHub Include="@(PackageReference)" Condition="'%(Identity)' == 'Microsoft.SourceLink.GitHub'" /> <_SourceLinkAzureRepos Include="@(PackageReference)" Condition="'%(Identity)' == 'Microsoft.SourceLink.AzureRepos.Git'" /> From f17ff4476f724acb443a7263531c69fb0397cad4 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 16:09:04 +1100 Subject: [PATCH 08/11] Fix erroneous spaces in solution discovery --- Sdk.Common.SolutionDiscovery.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sdk.Common.SolutionDiscovery.props b/Sdk.Common.SolutionDiscovery.props index 085f908..08fbe1d 100644 --- a/Sdk.Common.SolutionDiscovery.props +++ b/Sdk.Common.SolutionDiscovery.props @@ -18,11 +18,11 @@ <_SolutionFileExists_Level4 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..\..\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..'))','*.sln').Length) - $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..') ) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..')) <_SolutionFileExists_Level5 Condition="'$(SolutionDir)' == '' And Exists('$(MSBuildProjectDirectory)\..\..\..\..')">$([System.IO.Directory]::GetFiles('$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\..\'))','*.sln').Length) - $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\..') ) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\..')) $(MSBuildProjectDirectory) From c5fe90d380841c8a94250ce87165823d43091066 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 16:21:17 +1100 Subject: [PATCH 09/11] Refactor RequiresSystemMemory method to simplify null checks on targetFramework --- Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs index 912c750..d444273 100644 --- a/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs +++ b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs @@ -117,7 +117,7 @@ private static void CheckPackageProperty(CompilationAnalysisContext context, str private static bool RequiresSystemMemory(string? targetFramework, string? targetFrameworkIdentifier) { - if (string.IsNullOrEmpty(targetFramework) || string.IsNullOrEmpty(targetFrameworkIdentifier)) + if (string.IsNullOrEmpty(targetFramework)) { return false; } @@ -128,7 +128,7 @@ private static bool RequiresSystemMemory(string? targetFramework, string? target return targetFrameworkIdentifier == ".NETStandard" || targetFrameworkIdentifier == ".NETFramework" || - targetFramework!.StartsWith("netcoreapp2", System.StringComparison.Ordinal); + targetFramework.StartsWith("netcoreapp2", System.StringComparison.Ordinal); } private static bool RequiresTaskExtensions(string? targetFramework, string? targetFrameworkIdentifier) From e26e1f131dda3454dbb5c813f809abda00be3b50 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 16:23:32 +1100 Subject: [PATCH 10/11] Enhance assembly name check to support strong-named assemblies in MissingInternalsVisibleToAttributeAnalyzer --- Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs index 9639d3a..4ae19bb 100644 --- a/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs +++ b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs @@ -86,7 +86,9 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) TypedConstant firstArg = attr.ConstructorArguments[0]; if (firstArg.Kind == TypedConstantKind.Primitive && firstArg.Value is string assemblyName) { - return assemblyName == testNamespace; + // Handle both non-strong-named ("MyTest") and strong-named ("MyTest, PublicKey=...") assemblies + return assemblyName == testNamespace || + assemblyName.StartsWith(testNamespace + ",", System.StringComparison.Ordinal); } } return false; From fd19ff6704bfd54aabf283b4aa5182532a0e81e4 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 18 Jan 2026 16:35:12 +1100 Subject: [PATCH 11/11] Add PolyGuard and PolyNullability properties for non-test projects --- Sdk/Sdk.targets | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sdk/Sdk.targets b/Sdk/Sdk.targets index 8b3921d..cccabf8 100644 --- a/Sdk/Sdk.targets +++ b/Sdk/Sdk.targets @@ -73,6 +73,11 @@ + + + true + true +