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/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/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..c447cf1
--- /dev/null
+++ b/Sdk.Analyzers/AddInternalsVisibleToAttributeCodeFixProvider.cs
@@ -0,0 +1,114 @@
+// 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("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);
+
+ // 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
new file mode 100644
index 0000000..509f7d0
--- /dev/null
+++ b/Sdk.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +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
new file mode 100644
index 0000000..e3690dd
--- /dev/null
+++ b/Sdk.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,7 @@
+; 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
+--------|----------|----------|-------
diff --git a/Sdk.Analyzers/KtsuAnalyzerBase.cs b/Sdk.Analyzers/KtsuAnalyzerBase.cs
new file mode 100644
index 0000000..87404cb
--- /dev/null
+++ b/Sdk.Analyzers/KtsuAnalyzerBase.cs
@@ -0,0 +1,19 @@
+// 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
+ ///
+ protected const string Category = "ktsu.Sdk";
+}
diff --git a/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs
new file mode 100644
index 0000000..4ae19bb
--- /dev/null
+++ b/Sdk.Analyzers/MissingInternalsVisibleToAttributeAnalyzer.cs
@@ -0,0 +1,111 @@
+// 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)
+ {
+ // Handle both non-strong-named ("MyTest") and strong-named ("MyTest, PublicKey=...") assemblies
+ return assemblyName == testNamespace ||
+ assemblyName.StartsWith(testNamespace + ",", System.StringComparison.Ordinal);
+ }
+ }
+ 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..d444273
--- /dev/null
+++ b/Sdk.Analyzers/MissingStandardPackagesAnalyzer.cs
@@ -0,0 +1,149 @@
+// 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);
+
+ // Get package reference properties (passed from MSBuild)
+
+ 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.GitHub (build-time-only package)
+
+ CheckPackageProperty(context, "Microsoft.SourceLink.GitHub", hasSourceLinkGitHub);
+
+ // 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")
+ {
+ CheckPackageProperty(context, "Polyfill", hasPolyfill);
+ }
+
+ // Check for System.Memory (conditional on target framework)
+
+ if (RequiresSystemMemory(targetFramework, targetFrameworkIdentifier))
+ {
+ CheckPackageProperty(context, "System.Memory", hasSystemMemory);
+ }
+
+ // Check for System.Threading.Tasks.Extensions (conditional on target framework)
+
+ if (RequiresTaskExtensions(targetFramework, targetFrameworkIdentifier))
+ {
+ CheckPackageProperty(context, "System.Threading.Tasks.Extensions", hasSystemThreadingTasksExtensions);
+ }
+ }
+
+ private static void CheckPackageProperty(CompilationAnalysisContext context, string packageName, string? hasPackageProperty)
+ {
+ // 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 hasPackage = hasPackageProperty == "true";
+
+ if (!hasPackage)
+ {
+ 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))
+ {
+ 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
new file mode 100644
index 0000000..1994c54
--- /dev/null
+++ b/Sdk.Analyzers/Sdk.Analyzers.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+ netstandard2.0
+ latest
+ enable
+ true
+ true
+
+
+ $(NoWarn);RS1038
+
+
+ Analyzer
+ false
+ true
+ true
+
+
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..08fbe1d
--- /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('$(MSBuildProjectDirectory)\..\..\..')">$([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..cccabf8 100644
--- a/Sdk/Sdk.targets
+++ b/Sdk/Sdk.targets
@@ -25,45 +25,55 @@
-
-
-
-
+
+
+
+ <_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
-
- all
-
+ true
+ false
-
- all
-
+ true
+ false
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..bac1b51
--- /dev/null
+++ b/scripts/make-analyzer-releases.ps1
@@ -0,0 +1,42 @@
+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"
+}
+
+# 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