diff --git a/.editorconfig b/.editorconfig index fb21635..3937b59 100644 --- a/.editorconfig +++ b/.editorconfig @@ -42,7 +42,7 @@ insert_final_newline = true [*.cs] indent_size = 4 insert_final_newline = true -charset = utf-8-bom +charset = utf-8 # C# naming conventions live in src/Config/NamingConvention.editorconfig # (single source of truth, dotnet/runtime aligned, shipped with the SDK package). diff --git a/Directory.Build.props b/Directory.Build.props index 1c2f2cb..3fa7e27 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,14 +1,26 @@ + + + true + + latest enable - true - + enable + true + true + latest-all - - - true + true + true + true - - + + true + + + + - \ No newline at end of file + diff --git a/src/Build/Common/Version.props b/src/Build/Common/Version.props index 0cdf282..85bb8ba 100644 --- a/src/Build/Common/Version.props +++ b/src/Build/Common/Version.props @@ -54,6 +54,7 @@ 1.29.1 + $(DotNetSdkVersion) 4.1.5 4.14.0 diff --git a/src/Config/ANcpLua.NET.Sdk.SingleFileApp.editorconfig b/src/Config/ANcpLua.NET.Sdk.SingleFileApp.editorconfig index 7d5226d..d186572 100644 --- a/src/Config/ANcpLua.NET.Sdk.SingleFileApp.editorconfig +++ b/src/Config/ANcpLua.NET.Sdk.SingleFileApp.editorconfig @@ -1,6 +1,6 @@ -# global_level must be higher than the base editorconfig files +# global_level must be higher than the base SDK-shipped globals is_global = true -global_level = 101 +global_level = 61 # MA0047: Declare types in namespaces dotnet_diagnostic.MA0047.severity = none diff --git a/src/Config/ANcpLua.NET.Sdk.Web.editorconfig b/src/Config/ANcpLua.NET.Sdk.Web.editorconfig index 73ac9da..4064d76 100644 --- a/src/Config/ANcpLua.NET.Sdk.Web.editorconfig +++ b/src/Config/ANcpLua.NET.Sdk.Web.editorconfig @@ -1,6 +1,6 @@ -# global_level must be higher than the base editorconfig files +# global_level must be higher than the base SDK-shipped globals is_global = true -global_level = 101 +global_level = 60 # CA1002: Do not expose generic lists dotnet_diagnostic.CA1002.severity = none diff --git a/src/Config/Analyzer.ANcpLua.Analyzers.editorconfig b/src/Config/Analyzer.ANcpLua.Analyzers.editorconfig index 13a4a69..543de2e 100644 --- a/src/Config/Analyzer.ANcpLua.Analyzers.editorconfig +++ b/src/Config/Analyzer.ANcpLua.Analyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 13 # AL0001: Prohibit reassignment of primary constructor parameters # Help link: https://ancplua.mintlify.app/analyzers/rules/AL0001 @@ -672,4 +673,3 @@ dotnet_diagnostic.AL0137.severity = warning # Help link: https://ancplua.mintlify.app/analyzers/rules/AL0138 # Enabled: True, Severity: warning dotnet_diagnostic.AL0138.severity = warning - diff --git a/src/Config/Analyzer.AwesomeAssertions.Analyzers.editorconfig b/src/Config/Analyzer.AwesomeAssertions.Analyzers.editorconfig index a9dfa05..80c3de0 100644 --- a/src/Config/Analyzer.AwesomeAssertions.Analyzers.editorconfig +++ b/src/Config/Analyzer.AwesomeAssertions.Analyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 14 # AwesomeAssertions0801: Code Smell # Enabled: True, Severity: warning @@ -21,4 +22,3 @@ dotnet_diagnostic.FAA0003.severity = suggestion # FAA0004: Replace NUnit assertion with Fluent Assertions equivalent # Enabled: True, Severity: suggestion dotnet_diagnostic.FAA0004.severity = suggestion - diff --git a/src/Config/Analyzer.Microsoft.CodeAnalysis.Analyzers.editorconfig b/src/Config/Analyzer.Microsoft.CodeAnalysis.Analyzers.editorconfig index cb7d27d..8018850 100644 --- a/src/Config/Analyzer.Microsoft.CodeAnalysis.Analyzers.editorconfig +++ b/src/Config/Analyzer.Microsoft.CodeAnalysis.Analyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 10 # Microsoft.CodeAnalysis.Analyzers - no configurable rules @@ -224,4 +225,3 @@ dotnet_diagnostic.RS2007.severity = warning # Help link: https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md # Enabled: True, Severity: warning dotnet_diagnostic.RS2008.severity = warning - diff --git a/src/Config/Analyzer.Microsoft.CodeAnalysis.BannedApiAnalyzers.editorconfig b/src/Config/Analyzer.Microsoft.CodeAnalysis.BannedApiAnalyzers.editorconfig index d9ccb33..1d738bb 100644 --- a/src/Config/Analyzer.Microsoft.CodeAnalysis.BannedApiAnalyzers.editorconfig +++ b/src/Config/Analyzer.Microsoft.CodeAnalysis.BannedApiAnalyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 12 # RS0030: Do not use banned APIs # Help link: https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.BannedApiAnalyzers/BannedApiAnalyzers.Help.md @@ -15,4 +16,3 @@ dotnet_diagnostic.RS0031.severity = warning # RS0035: External access to internal symbols outside the restricted namespace(s) is prohibited # Enabled: True, Severity: error dotnet_diagnostic.RS0035.severity = error - diff --git a/src/Config/Analyzer.Microsoft.CodeAnalysis.NetAnalyzers.editorconfig b/src/Config/Analyzer.Microsoft.CodeAnalysis.NetAnalyzers.editorconfig index e5f9dd1..d2f0b71 100644 --- a/src/Config/Analyzer.Microsoft.CodeAnalysis.NetAnalyzers.editorconfig +++ b/src/Config/Analyzer.Microsoft.CodeAnalysis.NetAnalyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 11 dotnet_diagnostic.CA2217.api_surface = all @@ -1602,4 +1603,3 @@ dotnet_diagnostic.CA5404.severity = warning # Help link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5405 # Enabled: False, Severity: warning dotnet_diagnostic.CA5405.severity = warning - diff --git a/src/Config/Analyzer.xunit.analyzers.editorconfig b/src/Config/Analyzer.xunit.analyzers.editorconfig index c7d8785..75f0241 100644 --- a/src/Config/Analyzer.xunit.analyzers.editorconfig +++ b/src/Config/Analyzer.xunit.analyzers.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 15 # xUnit1000: Test classes must be public # Help link: https://xunit.net/xunit.analyzers/rules/xUnit1000 @@ -451,4 +452,3 @@ dotnet_diagnostic.xUnit3002.severity = warning # Help link: https://xunit.net/xunit.analyzers/rules/xUnit3003 # Enabled: True, Severity: warning dotnet_diagnostic.xUnit3003.severity = warning - diff --git a/src/Config/CodingStyle.editorconfig b/src/Config/CodingStyle.editorconfig index cd5a81c..29c409f 100644 --- a/src/Config/CodingStyle.editorconfig +++ b/src/Config/CodingStyle.editorconfig @@ -1,12 +1,9 @@ is_global = true -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. -global_level = 100 +global_level = 30 -charset = utf-8-bom -indent_style = space -indent_size = 4 -insert_final_newline = true -trim_trailing_whitespace = true +# Note: global AnalyzerConfig files aren't intended for editor-only formatting +# settings (indent size, trimming whitespace, encoding, ...). Keep those in a +# regular repo-level `.editorconfig` instead. # New line preferences csharp_new_line_before_open_brace = all @@ -26,7 +23,7 @@ csharp_indent_switch_labels = true csharp_indent_labels = one_less_than_current # Modifier preferences -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async # avoid this. unless absolutely necessary dotnet_style_qualification_for_field = false @@ -34,17 +31,13 @@ dotnet_style_qualification_for_property = false dotnet_style_qualification_for_method = false dotnet_style_qualification_for_event = false -# Types: use keywords instead of BCL types, and permit var only when the type is clear +# Types: use keywords instead of BCL types; prefer `var` consistently csharp_style_var_for_built_in_types = true csharp_style_var_when_type_is_apparent = true csharp_style_var_elsewhere = true dotnet_style_predefined_type_for_locals_parameters_members = true dotnet_style_predefined_type_for_member_access = true -# Use language keywords instead of framework type names for type references -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true - # File style csharp_using_directive_placement = outside_namespace dotnet_sort_system_directives_first = true @@ -53,6 +46,7 @@ csharp_style_namespace_declarations = file_scoped # Expression-level preferences dotnet_style_coalesce_expression = true dotnet_style_collection_initializer = true +dotnet_style_prefer_collection_expression = when_types_exactly_match dotnet_style_explicit_tuple_names = true dotnet_style_null_propagation = true dotnet_style_object_initializer = true @@ -70,7 +64,7 @@ csharp_prefer_inferred_anonymous_type_member_names = true csharp_prefer_inferred_tuple_names = true csharp_prefer_simple_default_expression = true csharp_style_deconstructed_variable_declaration = true -csharp_style_pattern_local_over_anonymous_function = true +csharp_style_prefer_local_over_anonymous_function = true csharp_style_prefer_switch_expression = true dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity @@ -99,7 +93,6 @@ csharp_style_throw_expression = true # Other features csharp_style_prefer_index_operator = true csharp_style_prefer_range_operator = true -csharp_style_pattern_local_over_anonymous_function = true # Code block preferences csharp_prefer_braces = when_multiline diff --git a/src/Config/Compiler.editorconfig b/src/Config/Compiler.editorconfig index 428ce9e..22f3fd1 100644 --- a/src/Config/Compiler.editorconfig +++ b/src/Config/Compiler.editorconfig @@ -1,6 +1,7 @@ -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. +# global_level controls precedence across multiple global AnalyzerConfig files. +# Keep distinct values to avoid equal-level conflicts (which are ignored). is_global = true -global_level = 100 +global_level = 40 # EnableGenerateDocumentationFile: Set MSBuild property 'GenerateDocumentationFile' to 'true' # Help link: https://github.com/dotnet/roslyn/issues/41640 @@ -298,4 +299,3 @@ dotnet_diagnostic.IDE2006.severity = silent # RemoveUnnecessaryImportsFixable: # Enabled: True, Severity: silent dotnet_diagnostic.RemoveUnnecessaryImportsFixable.severity = silent - diff --git a/src/Config/Global.editorconfig b/src/Config/Global.editorconfig index 99125fd..28d0e2c 100644 --- a/src/Config/Global.editorconfig +++ b/src/Config/Global.editorconfig @@ -1,8 +1,10 @@ +# Highest precedence among the base/non-flavor SDK globals. +# Per-flavor overlays (e.g. ANcpLua.NET.Sdk.Web=60, .SingleFileApp=61) sit above this. +# EditorConfig spec: comments must be on their own line, never trailing a value. is_global = true -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. -global_level = 100 +global_level = 50 -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_foreach_explicit_cast_in_source=when_strongly_typed # Remove this or Me qualification dotnet_diagnostic.IDE0003.severity = suggestion diff --git a/src/Config/NamingConvention.editorconfig b/src/Config/NamingConvention.editorconfig index ed2f205..4ecdb56 100644 --- a/src/Config/NamingConvention.editorconfig +++ b/src/Config/NamingConvention.editorconfig @@ -1,6 +1,5 @@ is_global = true -# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults. -global_level = 100 +global_level = 20 dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case diff --git a/tests/ANcpLua.Sdk.Tests/Helpers/SdkProjectBuilder.cs b/tests/ANcpLua.Sdk.Tests/Helpers/SdkProjectBuilder.cs index 1f9cc86..5c38d79 100644 --- a/tests/ANcpLua.Sdk.Tests/Helpers/SdkProjectBuilder.cs +++ b/tests/ANcpLua.Sdk.Tests/Helpers/SdkProjectBuilder.cs @@ -332,14 +332,16 @@ public static SdkProjectBuilder Create( public new SdkProjectBuilder WithDirectoryBuildProps(string content) { // Inject DisableVersionAnalyzer to prevent AL0017-AL0019 errors in test projects - var modifiedContent = content.Replace( - "", - """ - - - true - - """); + var doc = XDocument.Parse(content, LoadOptions.PreserveWhitespace); + if (doc.Root is null || doc.Root.Name.LocalName != "Project") + throw new InvalidOperationException("Directory.Build.props must have a root element."); + + var ns = doc.Root.Name.Namespace; + doc.Root.AddFirst( + new XElement(ns + "PropertyGroup", + new XElement(ns + "DisableVersionAnalyzer", "true"))); + + var modifiedContent = doc.ToString(SaveOptions.DisableFormatting); base.WithDirectoryBuildProps(modifiedContent); return this; } @@ -579,14 +581,14 @@ public override async Task ExecuteDotnetCommandAsync( var result = await psi.RunAsTaskAsync(); // Retry on SDK resolution errors - const int maxRetries = 5; - for (var retry = 0; retry < maxRetries && result.ExitCode is not 0; retry++) + const int MaxRetries = 5; + for (var retry = 0; retry < MaxRetries && result.ExitCode is not 0; retry++) if (result.Output.Any(static line => line.Text.Contains("error MSB4236", StringComparison.Ordinal) || line.Text.Contains( "The project file may be invalid or missing targets required for restore", StringComparison.Ordinal))) { - Output?.WriteLine($"SDK resolution or restore error detected, retrying ({retry + 1}/{maxRetries})..."); + Output?.WriteLine($"SDK resolution or restore error detected, retrying ({retry + 1}/{MaxRetries})..."); await Task.Delay(100 * (1 << retry)); result = await psi.RunAsTaskAsync(); } @@ -643,7 +645,7 @@ public override async Task ExecuteDotnetCommandAsync( } // Fail fast on SDK resolution errors - if (result.Output.Any(static line => line.Text.Contains("Could not resolve SDK"))) + if (result.Output.Any(static line => line.Text.Contains("Could not resolve SDK", StringComparison.Ordinal))) Assert.Fail("The SDK cannot be found, expected version: " + _fixture.Version); return new BuildResult(result.ExitCode, result.Output, sarif, binlogContent) diff --git a/tests/ANcpLua.Sdk.Tests/MtpDetectionTests.cs b/tests/ANcpLua.Sdk.Tests/MtpDetectionTests.cs index cfa51f2..fa75ee0 100644 --- a/tests/ANcpLua.Sdk.Tests/MtpDetectionTests.cs +++ b/tests/ANcpLua.Sdk.Tests/MtpDetectionTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using static ANcpLua.Sdk.Tests.Helpers.PackageFixture; namespace ANcpLua.Sdk.Tests; @@ -42,6 +43,8 @@ public abstract class MtpDetectionTests( "TestingPlatformCommandLineArguments" ]; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Builder factory transfers ownership; every caller disposes via 'await using var project = CreateProject(...);'.")] private SdkProjectBuilder CreateProject(string? sdkName = null) => SdkProjectBuilder.Create(fixture, SdkImportStyle.ProjectElement, sdkName ?? SdkTestName) .WithDotnetSdkVersion(dotnetSdkVersion) @@ -121,10 +124,10 @@ public class SampleTests .BuildAsync(); var cliArgs = result.GetRecordedProperty("TestingPlatformCommandLineArguments"); - Assert.Contains("--report-xunit-trx", cliArgs); - Assert.DoesNotContain("--report-trx ", cliArgs); - Assert.DoesNotContain("--crashdump", cliArgs); - Assert.DoesNotContain("--hangdump", cliArgs); + Assert.Contains("--report-xunit-trx", cliArgs, StringComparison.Ordinal); + Assert.DoesNotContain("--report-trx", cliArgs, StringComparison.Ordinal); + Assert.DoesNotContain("--crashdump", cliArgs, StringComparison.Ordinal); + Assert.DoesNotContain("--hangdump", cliArgs, StringComparison.Ordinal); } [Fact] diff --git a/tests/ANcpLua.Sdk.Tests/SdkTests.cs b/tests/ANcpLua.Sdk.Tests/SdkTests.cs index 77168da..06875d9 100644 --- a/tests/ANcpLua.Sdk.Tests/SdkTests.cs +++ b/tests/ANcpLua.Sdk.Tests/SdkTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -37,6 +38,8 @@ public abstract class SdkTests( "_IsGitHubActions" ]; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Builder factory transfers ownership; every caller disposes via 'await using var project = CreateProject(...);'.")] private SdkProjectBuilder CreateProject(string? sdkName = null) => SdkProjectBuilder.Create(fixture, sdkImportStyle, sdkName ?? SdkName) .WithDotnetSdkVersion(dotnetSdkVersion) @@ -71,7 +74,7 @@ static bool IsCpmItemGroup(XElement? parent) if (parent?.Name.LocalName != "ItemGroup") return false; var condition = parent.Attribute("Condition")?.Value; - return condition?.Contains("ManagePackageVersionsCentrally") == true; + return condition?.Contains("ManagePackageVersionsCentrally", StringComparison.Ordinal) == true; } } @@ -1040,7 +1043,7 @@ public async Task SetTargetFramework(string propName, string version) _ = blobReader.ReadSerializedString(); var key = blobReader.ReadSerializedString(); - Assert.Contains(expectedVersion.Replace("net", "v", StringComparison.Ordinal), key); + Assert.Contains(expectedVersion.Replace("net", "v", StringComparison.Ordinal), key, StringComparison.Ordinal); return; } } @@ -1332,7 +1335,7 @@ private static async Task AssertDebugInfoExists(IReadOnlyCollection outp { var dllPath = outputFiles.Single(static f => f.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); await using var stream = File.OpenRead(dllPath); - var peReader = new PEReader(stream); + using var peReader = new PEReader(stream); var debug = peReader.ReadDebugDirectory(); if (isPackOutput) diff --git a/tests/ANcpLua.Sdk.Tests/SourceGeneratorDefaultsTests.cs b/tests/ANcpLua.Sdk.Tests/SourceGeneratorDefaultsTests.cs index 02a0356..00d3349 100644 --- a/tests/ANcpLua.Sdk.Tests/SourceGeneratorDefaultsTests.cs +++ b/tests/ANcpLua.Sdk.Tests/SourceGeneratorDefaultsTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using static ANcpLua.Sdk.Tests.Helpers.PackageFixture; namespace ANcpLua.Sdk.Tests; @@ -29,6 +30,8 @@ public abstract class SourceGeneratorDefaultsTests( "RoslynVersion" ]; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Builder factory transfers ownership; every caller disposes via 'await using var project = CreateProject(...);'.")] private SdkProjectBuilder CreateProject(string sdkName = SdkName) => SdkProjectBuilder.Create(fixture, SdkImportStyle.ProjectElement, sdkName) .WithDotnetSdkVersion(dotnetSdkVersion) diff --git a/tests/ANcpLua.Sdk.Tests/TemplatesTests.cs b/tests/ANcpLua.Sdk.Tests/TemplatesTests.cs index 496c084..af625bd 100644 --- a/tests/ANcpLua.Sdk.Tests/TemplatesTests.cs +++ b/tests/ANcpLua.Sdk.Tests/TemplatesTests.cs @@ -17,8 +17,6 @@ public sealed partial class TemplatesTests(PackageFixture fixture) { private const string TemplatesPackageId = "ANcpLua.NET.Sdk.Templates"; - private static readonly string[] s_shortNames = ["ancplua-app", "ancplua-lib", "ancplua-web"]; - // Concurrent dotnet-build invocations against scaffolded projects step on each other // via the shared MSBuild build server (node reuse) and the shared NuGet global packages // folder — manifests as flaky "file is being used by another process" errors in @@ -80,8 +78,8 @@ public async Task Pack_TemplateJson_HasNoUnsubstitutedPlaceholders(string shortN { var content = await ReadEntryAsync($"content/{shortName}/.template.config/template.json"); - Assert.DoesNotContain("__PACK_TIME_SDK_VERSION__", content); - Assert.DoesNotContain("__PACK_TIME_DOTNET_SDK_VERSION__", content); + Assert.DoesNotContain("__PACK_TIME_SDK_VERSION__", content, StringComparison.Ordinal); + Assert.DoesNotContain("__PACK_TIME_DOTNET_SDK_VERSION__", content, StringComparison.Ordinal); } [Theory] @@ -140,8 +138,8 @@ public async Task Pack_GlobalJson_HasPlaceholders_ForScaffoldTimeSubstitution(st // symbol values (which template.json's defaults provide). var content = await ReadEntryAsync($"content/{shortName}/global.json"); - Assert.Contains("ANCPLUA_SDK_VERSION_PLACEHOLDER", content); - Assert.Contains("ANCPLUA_DOTNET_SDK_VERSION_PLACEHOLDER", content); + Assert.Contains("ANCPLUA_SDK_VERSION_PLACEHOLDER", content, StringComparison.Ordinal); + Assert.Contains("ANCPLUA_DOTNET_SDK_VERSION_PLACEHOLDER", content, StringComparison.Ordinal); } [Fact] @@ -152,9 +150,9 @@ public async Task Pack_WebTemplate_GlobalJsonPinsAllThreeMsbuildSdks() // ANcpLua.NET.Sdk.Test for the tests project. var content = await ReadEntryAsync("content/ancplua-web/global.json"); - Assert.Contains("\"ANcpLua.NET.Sdk\":", content); - Assert.Contains("\"ANcpLua.NET.Sdk.Web\":", content); - Assert.Contains("\"ANcpLua.NET.Sdk.Test\":", content); + Assert.Contains("\"ANcpLua.NET.Sdk\":", content, StringComparison.Ordinal); + Assert.Contains("\"ANcpLua.NET.Sdk.Web\":", content, StringComparison.Ordinal); + Assert.Contains("\"ANcpLua.NET.Sdk.Test\":", content, StringComparison.Ordinal); } [Theory] @@ -184,9 +182,9 @@ public async Task Scaffold_ProducesNamedTreeWithVersionPins(string shortName, st // stamped at pack time). var globalJsonPath = output.FullPath / "global.json"; var globalJson = await File.ReadAllTextAsync(globalJsonPath, TestContext.Current.CancellationToken); - Assert.DoesNotContain("ANCPLUA_SDK_VERSION_PLACEHOLDER", globalJson); - Assert.DoesNotContain("ANCPLUA_DOTNET_SDK_VERSION_PLACEHOLDER", globalJson); - Assert.Contains($"\"ANcpLua.NET.Sdk\": \"{fixture.Version}\"", globalJson); + Assert.DoesNotContain("ANCPLUA_SDK_VERSION_PLACEHOLDER", globalJson, StringComparison.Ordinal); + Assert.DoesNotContain("ANCPLUA_DOTNET_SDK_VERSION_PLACEHOLDER", globalJson, StringComparison.Ordinal); + Assert.Contains($"\"ANcpLua.NET.Sdk\": \"{fixture.Version}\"", globalJson, StringComparison.Ordinal); // Verify the scaffolded sdk.version pins to the .NET SDK we stamped at pack time // (read from Build/Common/Version.props, propagated through DotNetSdkVersion symbol). @@ -210,7 +208,7 @@ public async Task Scaffold_ProducesNamedTreeWithVersionPins(string shortName, st var dirPackagesPath = output.FullPath / "Directory.Packages.props"; Assert.True(File.Exists(dirPackagesPath)); var dirPackages = await File.ReadAllTextAsync(dirPackagesPath, TestContext.Current.CancellationToken); - Assert.Contains("true", dirPackages); + Assert.Contains("true", dirPackages, StringComparison.Ordinal); } [Theory] @@ -243,10 +241,10 @@ await DotnetNewScaffoldAsync( var srcContent = await File.ReadAllTextAsync(srcCsproj, TestContext.Current.CancellationToken); var testsContent = await File.ReadAllTextAsync(testsCsproj, TestContext.Current.CancellationToken); - Assert.Contains("net9.0", srcContent); - Assert.Contains("net9.0", testsContent); - Assert.DoesNotContain("net10.0", srcContent); - Assert.DoesNotContain("net10.0", testsContent); + Assert.Contains("net9.0", srcContent, StringComparison.Ordinal); + Assert.Contains("net9.0", testsContent, StringComparison.Ordinal); + Assert.DoesNotContain("net10.0", srcContent, StringComparison.Ordinal); + Assert.DoesNotContain("net10.0", testsContent, StringComparison.Ordinal); } [Theory] diff --git a/tools/ConfigFilesGenerator/Program.cs b/tools/ConfigFilesGenerator/Program.cs index be22c25..286b675 100644 --- a/tools/ConfigFilesGenerator/Program.cs +++ b/tools/ConfigFilesGenerator/Program.cs @@ -20,17 +20,31 @@ using NuGet.Protocol.Core.Types; using NuGet.Versioning; -var rootFolder = GetRootFolderPath(); - -// See Microsoft Learn: Global AnalyzerConfig precedence. -// We intentionally set global_level to 100 so our SDK-provided configuration wins over -// analyzer-provided defaults (typically global_level=0), while still allowing repo-local -// `.editorconfig` files to override (EditorConfig wins over global AnalyzerConfig). -const int SdkGlobalAnalyzerConfigLevel = 100; +const int CompilerAnalyzerConfigGlobalLevel = 40; +var rootFolder = GetRootFolderPath(); var msbuildProperties = LoadMsBuildProperties(rootFolder); -if (!msbuildProperties.TryGetValue("XunitV3Version", out var _)) - Console.WriteLine("Warning: failed to load MSBuild properties from Version.props (missing XunitV3Version)."); + +// Pin SDK-injected analyzers to the versions declared in Version.props so the +// generator always refreshes the configs the SDK actually ships, regardless of +// whether DependencyScanner sees the property-based PackageReference values. +var injectedAnalyzerPackages = new (string PackageId, string PropertyName)[] +{ + ("Microsoft.CodeAnalysis.NetAnalyzers", "NetAnalyzersVersion"), + ("ANcpLua.Analyzers", "ANcpLuaAnalyzersVersion"), + ("Microsoft.CodeAnalysis.BannedApiAnalyzers", "BannedApiAnalyzersVersion"), + ("AwesomeAssertions.Analyzers", "AwesomeAssertionsAnalyzersVersion"), +}; + +var injectedAnalyzerVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); +foreach (var (packageId, propertyName) in injectedAnalyzerPackages) +{ + var resolved = ResolveMsBuildProperty($"$({propertyName})", msbuildProperties); + if (string.IsNullOrWhiteSpace(resolved) || ContainsUnresolvedProperty(resolved)) + throw new InvalidOperationException( + $"Could not resolve {propertyName} from src/Build/Common/Version.props."); + injectedAnalyzerVersions[packageId] = resolved; +} var writtenFiles = 0; await GenerateEditorConfigForCompilerAnalyzers().ConfigureAwait(false); @@ -40,9 +54,9 @@ Console.WriteLine($"{writtenFiles} configuration files written"); if (writtenFiles > 0) { - using var diffProcess = Process.Start("git", "--no-pager diff --color") ?? - throw new InvalidOperationException("Cannot start 'git --no-pager diff --color'"); - await diffProcess.WaitForExitAsync().ConfigureAwait(false); + using var diffProcess = Process.Start("git", "--no-pager diff --color"); + if (diffProcess is not null) + await diffProcess.WaitForExitAsync().ConfigureAwait(false); } return 0; @@ -70,9 +84,10 @@ async Task GenerateEditorConfigForCompilerAnalyzers() if (rules.Count > 0) { var sb = new StringBuilder(); - sb.AppendLine("# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults."); + sb.AppendLine("# global_level controls precedence across multiple global AnalyzerConfig files."); + sb.AppendLine("# Keep distinct values to avoid equal-level conflicts (which are ignored)."); sb.AppendLine("is_global = true"); - sb.AppendLine($"global_level = {SdkGlobalAnalyzerConfigLevel}"); + sb.AppendLine($"global_level = {CompilerAnalyzerConfigGlobalLevel}"); var currentConfiguration = GetConfiguration(configurationFilePath); @@ -90,7 +105,8 @@ async Task GenerateEditorConfigForCompilerAnalyzers() : rule.DefaultEffectiveSeverity; sb.AppendLine($"# {rule.Id}: {rule.Title}"); - if (!string.IsNullOrEmpty(rule.Url)) sb.AppendLine($"# Help link: {NormalizeHelpLink(rule.Url, rule.Id)}"); + var helpLink = NormalizeHelpLink(rule.Url, rule.Id); + if (!string.IsNullOrEmpty(helpLink)) sb.AppendLine($"# Help link: {helpLink}"); sb.AppendLine($"# Enabled: {rule.Enabled}, Severity: {GetSeverity(rule.DefaultSeverity)}"); if (currentRuleConfiguration?.Comments.Length > 0) @@ -101,7 +117,7 @@ async Task GenerateEditorConfigForCompilerAnalyzers() sb.AppendLine(); } - var text = sb.ToString().ReplaceLineEndings("\n"); + var text = NormalizeGeneratedText(sb); if (File.Exists(configurationFilePath)) if (File.ReadAllText(configurationFilePath).ReplaceLineEndings("\n") == text) return; @@ -117,7 +133,7 @@ async Task GenerateEditorConfigForCompilerAnalyzers() DiagnosticSeverity.Info => "suggestion", DiagnosticSeverity.Warning => "warning", DiagnosticSeverity.Error => "error", - _ => throw new Exception($"Severity '{severity}' is not supported") + _ => throw new InvalidOperationException($"Severity '{severity}' is not supported") }; } } @@ -243,6 +259,7 @@ static Assembly[] LoadAssembliesFromDisk(string[] assemblyPaths, string[] probeF async Task GenerateEditorConfigForAnalyzers() { var packages = await GetAllReferencedNuGetPackages().ConfigureAwait(false); + var globalLevelsByPackageId = GetAnalyzerConfigGlobalLevels(packages.Select(static p => p.Id)); await Parallel.ForEachAsync(packages, async (item, cancellationToken) => { var (packageId, packageVersion) = item; @@ -273,9 +290,10 @@ await Parallel.ForEachAsync(packages, async (item, cancellationToken) => if (rules.Count > 0) { var sb = new StringBuilder(); - sb.AppendLine("# global_level is set to 100 so SDK configuration overrides analyzer-provided defaults."); + sb.AppendLine("# global_level controls precedence across multiple global AnalyzerConfig files."); + sb.AppendLine("# Keep distinct values to avoid equal-level conflicts (which are ignored)."); sb.AppendLine("is_global = true"); - sb.AppendLine($"global_level = {SdkGlobalAnalyzerConfigLevel}"); + sb.AppendLine($"global_level = {globalLevelsByPackageId[packageId]}"); var currentConfiguration = GetConfiguration(configurationFilePath); @@ -293,7 +311,8 @@ await Parallel.ForEachAsync(packages, async (item, cancellationToken) => : rule.DefaultEffectiveSeverity; sb.AppendLine($"# {rule.Id}: {rule.Title}"); - if (!string.IsNullOrEmpty(rule.Url)) sb.AppendLine($"# Help link: {NormalizeHelpLink(rule.Url, rule.Id)}"); + var helpLink = NormalizeHelpLink(rule.Url, rule.Id); + if (!string.IsNullOrEmpty(helpLink)) sb.AppendLine($"# Help link: {helpLink}"); sb.AppendLine($"# Enabled: {rule.Enabled}, Severity: {GetSeverity(rule.DefaultSeverity)}"); @@ -305,7 +324,7 @@ await Parallel.ForEachAsync(packages, async (item, cancellationToken) => sb.AppendLine(); } - var text = sb.ToString().ReplaceLineEndings("\n"); + var text = NormalizeGeneratedText(sb); if (File.Exists(configurationFilePath)) if (File.ReadAllText(configurationFilePath).ReplaceLineEndings("\n") == text) return; @@ -323,7 +342,7 @@ static string GetSeverity(DiagnosticSeverity? severity) DiagnosticSeverity.Info => "suggestion", DiagnosticSeverity.Warning => "warning", DiagnosticSeverity.Error => "error", - _ => throw new Exception($"Severity '{severity}' is not supported") + _ => throw new InvalidOperationException($"Severity '{severity}' is not supported") }; } } @@ -370,7 +389,7 @@ async Task GenerateBanSymbolsForNewtonsoftJson() sb.AppendLine("# Banned symbols from Newtonsoft.Json"); foreach (var symbol in bannedSymbols.OrderBy(static s => s, StringComparer.Ordinal)) sb.AppendLine(symbol); - var text = sb.ToString().ReplaceLineEndings("\n"); + var text = NormalizeGeneratedText(sb); if (File.Exists(bannedSymbolsFilePath)) if (File.ReadAllText(bannedSymbolsFilePath).ReplaceLineEndings("\n") == text) return; @@ -385,9 +404,143 @@ FullPath GetAnalyzerConfigurationFilePath(string packageId) return rootFolder / "src" / "Config" / ("Analyzer." + packageId + ".editorconfig"); } +static string NormalizeGeneratedText(StringBuilder sb) +{ + return sb.ToString().ReplaceLineEndings("\n").TrimEnd('\n') + "\n"; +} + +static string? NormalizeHelpLink(string? helpLinkUri, string ruleId) +{ + // Some analyzers (e.g. ANcpLua.Analyzers AL0019) return the rules-index URL + // without the rule slug. Append the rule ID so the generated link points at + // the rule-specific page rather than the index. + if (string.IsNullOrEmpty(helpLinkUri)) + return helpLinkUri; + + return helpLinkUri.EndsWith("/rules/", StringComparison.Ordinal) + ? helpLinkUri + ruleId + : helpLinkUri; +} + +static IReadOnlyDictionary GetAnalyzerConfigGlobalLevels(IEnumerable packageIds) +{ + var orderedIds = packageIds + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static id => id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // Stable, deterministic, collision-free within the referenced package set. + // Low range is reserved for known "core" analyzer packs. NuGet IDs are + // case-insensitive, so the reserved-ID matches must be case-insensitive too. + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var assigned = new HashSet(); + + foreach (var id in orderedIds) + { + var reserved = id switch + { + _ when string.Equals(id, "Microsoft.CodeAnalysis.Analyzers", StringComparison.OrdinalIgnoreCase) => 10, + _ when string.Equals(id, "Microsoft.CodeAnalysis.NetAnalyzers", StringComparison.OrdinalIgnoreCase) => 11, + _ when string.Equals(id, "Microsoft.CodeAnalysis.BannedApiAnalyzers", StringComparison.OrdinalIgnoreCase) => 12, + _ when string.Equals(id, "ANcpLua.Analyzers", StringComparison.OrdinalIgnoreCase) => 13, + _ when string.Equals(id, "AwesomeAssertions.Analyzers", StringComparison.OrdinalIgnoreCase) => 14, + // xunit.analyzers ships as a transitive of xunit.v3 (resolved via Version.props + // property expansion). Reserved so its config doesn't churn when the package set shifts. + _ when string.Equals(id, "xunit.analyzers", StringComparison.OrdinalIgnoreCase) => 15, + _ => -1 + }; + + if (reserved >= 0) + { + result[id] = reserved; + assigned.Add(reserved); + continue; + } + + // Derive a stable level from the package ID itself (FNV-1a over the + // upper-cased UTF-8 bytes is process-deterministic, unlike the runtime's + // randomized GetHashCode). Linear-probe on collision so adding or + // removing unrelated packages doesn't renumber neighbors. + var level = (int)(StableFnv1a(id) % 9000UL) + 1000; + while (assigned.Contains(level)) + level = level == 9999 ? 1000 : level + 1; + + result[id] = level; + assigned.Add(level); + } + + return result; + + static ulong StableFnv1a(string value) + { + const ulong FnvOffsetBasis = 14695981039346656037UL; + const ulong FnvPrime = 1099511628211UL; + var bytes = System.Text.Encoding.UTF8.GetBytes(value.ToUpperInvariant()); + var hash = FnvOffsetBasis; + foreach (var b in bytes) + { + hash ^= b; + hash *= FnvPrime; + } + return hash; + } +} + +static IReadOnlyDictionary LoadMsBuildProperties(FullPath rootFolder) +{ + var versionPropsPath = rootFolder / "src" / "Build" / "Common" / "Version.props"; + if (!File.Exists(versionPropsPath)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var doc = XDocument.Load(versionPropsPath); + var root = doc.Root; + if (root is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Honor the only Condition pattern used in Version.props: the standard + // "default if undefined" guard, e.g. Condition="'$(NetAnalyzersVersion)' == ''". + // For that pattern, only assign when the property is not already set + // (mirrors MSBuild's evaluation of a self-referential empty check). + // Unconditional assignments behave normally (last write wins). + // Anything else with a Condition is skipped to avoid silently mishandling + // a guard the loader doesn't understand. + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var propertyGroup in root.Elements("PropertyGroup")) + foreach (var element in propertyGroup.Elements()) + { + var value = element.Value?.Trim(); + if (string.IsNullOrEmpty(value)) + continue; + + var name = element.Name.LocalName; + var condition = element.Attribute("Condition")?.Value; + if (string.IsNullOrWhiteSpace(condition)) + { + properties[name] = value; + continue; + } + + var selfDefaultGuard = $"'$({name})' == ''"; + if (string.Equals(NormalizeCondition(condition), selfDefaultGuard, StringComparison.Ordinal)) + { + if (!properties.ContainsKey(name)) + properties[name] = value; + continue; + } + + // Unknown Condition shape: don't assume it's true. + } + + return properties; + + static string NormalizeCondition(string condition) => + Regex.Replace(condition, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); +} + async Task<(string Id, NuGetVersion Version)[]> GetAllReferencedNuGetPackages() { var foundPackages = new HashSet(); + var visitedPackages = new HashSet(PackageIdentityComparer.Default); using var cache = new SourceCacheContext(); var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); @@ -407,10 +560,45 @@ FullPath GetAnalyzerConfigurationFilePath(string packageId) var packageIdentity = new PackageIdentity(package.Id, version); await ListAllPackageDependencies(packageIdentity, [repository], NuGetFramework.AnyFramework, cache, - NullLogger.Instance, foundPackages, CancellationToken.None).ConfigureAwait(false); + NullLogger.Instance, foundPackages, visitedPackages, CancellationToken.None).ConfigureAwait(false); } - return [.. foundPackages.Select(static p => (p.Id, p.Version))]; + // Deduplicate by package ID. For SDK-injected analyzers, honor the version + // pinned via Version.props so the generated configs reflect the analyzer set + // the SDK actually injects, even if a transitive dep pulls a newer version. + // For everything else, prefer the highest version to capture all analyzer rules. + var pinnedVersions = injectedAnalyzerVersions.ToDictionary( + static kvp => kvp.Key, + static kvp => NuGetVersion.Parse(kvp.Value), + StringComparer.OrdinalIgnoreCase); + + // ListAllPackageDependencies silently `continue`s when ResolvePackage returns + // null (e.g. the pinned version doesn't exist on the feed), so a missing + // pinned analyzer would otherwise silently drop out of the generated set. + // Fail loudly instead. + var foundIds = foundPackages + .Select(static p => p.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pinnedId in pinnedVersions.Keys) + if (!foundIds.Contains(pinnedId)) + throw new InvalidOperationException( + $"Pinned analyzer package '{pinnedId}@{pinnedVersions[pinnedId]}' could not be resolved on nuget.org."); + + var deduplicatedPackages = foundPackages + .GroupBy(static p => p.Id, StringComparer.OrdinalIgnoreCase) + .Select(g => + { + if (pinnedVersions.TryGetValue(g.Key, out var pinnedVersion)) + return g.FirstOrDefault(p => p.Version == pinnedVersion) + ?? throw new InvalidOperationException( + $"Pinned analyzer package '{g.Key}' version '{pinnedVersion}' was not resolved."); + + return g.OrderByDescending(static p => p.Version).First(); + }) + .Select(static p => (p.Id, p.Version)) + .ToArray(); + + return deduplicatedPackages; static async Task ListAllPackageDependencies( PackageIdentity package, @@ -419,10 +607,17 @@ static async Task ListAllPackageDependencies( SourceCacheContext cache, ILogger logger, ISet dependencies, + ISet visited, CancellationToken cancellationToken) { + // Track visited identities in a typed PackageIdentity set so the + // early-exit check is an O(1) hash lookup (using NuGet's canonical + // PackageIdentityComparer) rather than the O(n) LINQ Contains that + // covariance silently produced when querying a HashSet typed for + // SourcePackageDependencyInfo with a base PackageIdentity argument. + if (!visited.Add(package)) return; + var sourceRepositories = repositories as IReadOnlyList ?? repositories.ToList(); - if (dependencies.Contains(package)) return; foreach (var repository in sourceRepositories) { @@ -443,6 +638,7 @@ await ListAllPackageDependencies( cache, logger, dependencies, + visited, cancellationToken).ConfigureAwait(false); } } @@ -450,126 +646,94 @@ await ListAllPackageDependencies( async IAsyncEnumerable<(string Id, string? Version)> GetReferencedNuGetPackages() { + // Dedupe on (id, resolved-version) tuples — NOT id alone — so that the + // same package showing up with two different versions (e.g. + // Microsoft.CodeAnalysis.CSharp without a version in src/*.csproj plus a + // VersionOverride in SourceGenerators.targets) both reach the downstream + // dedup pass, which already prefers pinned versions and falls back to + // the highest. Identical (id, version) pairs are still skipped to avoid + // redundant NuGet dependency walks. + var emittedKeys = new HashSet<(string Id, string? Version)>(VersionedPackageKeyComparer.Instance); + var emittedIds = new HashSet(StringComparer.OrdinalIgnoreCase); var result = await DependencyScanner.ScanDirectoryAsync(rootFolder / "src", null).ConfigureAwait(false); foreach (var item in result) - if (item.Type is DependencyType.NuGet && item.Name is not null) + { + if (item.Type is not DependencyType.NuGet || item.Name is null) + continue; + + var version = item.Version; + // Centrally-managed PackageReferences carry property-based versions + // (e.g. "$(XunitV3Version)"). Resolve them via Version.props so their + // transitive analyzers (xunit.analyzers, etc.) still get scanned. + // Drop only when resolution still leaves an unresolved property. + if (version is not null && version.Contains("$(", StringComparison.Ordinal)) { - var version = TryResolveMsBuildProperty(item.Version, msbuildProperties); - yield return (item.Name, ContainsUnresolvedProperty(version) ? null : version); + version = ResolveMsBuildProperty(version, msbuildProperties); + if (ContainsUnresolvedProperty(version)) + continue; } - foreach (var package in new[] - { - // Analyzer packages injected by the SDK, not necessarily referenced by this repo's own csproj files. - "Microsoft.CodeAnalysis.NetAnalyzers", - "Microsoft.CodeAnalysis.BannedApiAnalyzers", - "ANcpLua.Analyzers", - "AwesomeAssertions.Analyzers" - }) - { - var propertyName = GetVersionPropertyName(package); - var version = TryResolveMsBuildProperty($"$({propertyName})", msbuildProperties); - if (string.IsNullOrWhiteSpace(version) || ContainsUnresolvedProperty(version)) - throw new InvalidOperationException( - $"Could not resolve '{propertyName}' for injected analyzer package '{package}'."); + if (!emittedKeys.Add((item.Name, version))) + continue; - yield return (package, version); + emittedIds.Add(item.Name); + yield return (item.Name, version); } - static bool ContainsUnresolvedProperty(string? value) => - value is not null && value.Contains("$(", StringComparison.Ordinal); + // Pin analyzer package versions to repo-declared versions so config + // generation reproducibly refreshes the analyzer set the SDK injects, + // even when DependencyScanner doesn't surface them at all. + foreach (var (packageId, version) in injectedAnalyzerVersions) + if (emittedIds.Add(packageId)) + yield return (packageId, version); } -static string NormalizeHelpLink(string url, string ruleId) -{ - if (url.EndsWith("/rules/", StringComparison.Ordinal) && ruleId.Length > 0) - return url + ruleId; - - return url; -} +static bool ContainsUnresolvedProperty(string? value) => + value is not null && value.Contains("$(", StringComparison.Ordinal); -static FullPath GetRootFolderPath() +static string ResolveMsBuildProperty(string value, IReadOnlyDictionary properties) { - var path = FullPath.CurrentDirectory(); - while (!path.IsEmpty) + // Recursively expand $(...) references with a bounded depth and cycle guard. + const int MaxDepth = 16; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var current = value; + for (var depth = 0; depth < MaxDepth; depth++) { - // Git worktrees use a `.git` *file* pointing at the actual gitdir. - // Regular clones use a `.git` directory. - if (Directory.Exists(path / ".git") || File.Exists(path / ".git")) - return path; + if (string.IsNullOrEmpty(current)) + return current; - path = path.Parent; - } + var match = Regex.Match(current, @"^\$\(([^)]+)\)$", RegexOptions.CultureInvariant); + if (!match.Success) + return current; - return path.IsEmpty ? throw new InvalidOperationException("Cannot find the root folder") : path; -} + var propertyName = match.Groups[1].Value; + if (!visited.Add(propertyName)) + return current; -static IReadOnlyDictionary LoadMsBuildProperties(FullPath rootFolder) -{ - // Directory.Packages.props imports src/Build/Common/Version.props and is the canonical version source for this repo. - // Keep parsing simple and conservative: read only direct property values. - var versionPropsPath = rootFolder / "src" / "Build" / "Common" / "Version.props"; - if (!File.Exists(versionPropsPath)) - return new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!properties.TryGetValue(propertyName, out var resolved)) + return current; - var doc = XDocument.Load(versionPropsPath); - var root = doc.Root; - if (root is null) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var propertyGroup in root.Elements("PropertyGroup")) - foreach (var element in propertyGroup.Elements()) - { - if (element.HasElements) - continue; - - var name = element.Name.LocalName; - var value = element.Value.Trim(); - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(value)) - continue; - - // First value wins to avoid accidentally capturing conditional overrides. - properties.TryAdd(name, value); + current = resolved; } - return properties; + return current; } -static string? TryResolveMsBuildProperty(string? value, IReadOnlyDictionary properties) +static FullPath GetRootFolderPath() { - if (string.IsNullOrWhiteSpace(value)) - return value; - - // Loop because Version.props chains properties (e.g. XunitMtpVersion = $(XunitV3Version)). - // The bound is defensive against a hypothetical reference cycle in the .props file. - var current = value.Trim(); - for (var i = 0; i < 16 && current.Contains("$(", StringComparison.Ordinal); i++) + var path = FullPath.CurrentDirectory(); + while (!path.IsEmpty) { - var next = Regex.Replace(current, "\\$\\((?[^)]+)\\)", match => - { - var name = match.Groups["Name"].Value; - return properties.TryGetValue(name, out var resolved) ? resolved : match.Value; - }); - if (string.Equals(next, current, StringComparison.Ordinal)) - break; - current = next; - } + // In a regular checkout `.git` is a directory; in a worktree it is a + // file pointing at the real gitdir. Accept both so worktree layouts work. + var gitMarker = path / ".git"; + if (Directory.Exists(gitMarker) || File.Exists(gitMarker)) + return path; - return current; -} + path = path.Parent; + } -static string GetVersionPropertyName(string packageId) -{ - // These properties are defined in src/Build/Common/Version.props. - return packageId switch - { - "Microsoft.CodeAnalysis.NetAnalyzers" => "DotNetSdkVersion", - "Microsoft.CodeAnalysis.BannedApiAnalyzers" => "BannedApiAnalyzersVersion", - "ANcpLua.Analyzers" => "ANcpLuaAnalyzersVersion", - "AwesomeAssertions.Analyzers" => "AwesomeAssertionsAnalyzersVersion", - _ => throw new InvalidOperationException($"No version property mapping for '{packageId}'.") - }; + return path.IsEmpty ? throw new InvalidOperationException("Cannot find the root folder") : path; } static async Task GetAnalyzerReferences(string packageId, NuGetVersion version) @@ -719,10 +883,10 @@ static async Task DownloadNuGetPackage(string packageId, { var settings = Settings.LoadDefaultSettings(null); var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(settings); - const string source = "https://api.nuget.org/v3/index.json"; + const string Source = "https://api.nuget.org/v3/index.json"; using var cache = new SourceCacheContext(); - var repository = Repository.Factory.GetCoreV3(source); + var repository = Repository.Factory.GetCoreV3(Source); var resource = await repository.GetResourceAsync().ConfigureAwait(false); if (version is null) @@ -750,7 +914,7 @@ await resource.CopyNupkgToStreamAsync( packageStream.Seek(0, SeekOrigin.Begin); package = await GlobalPackagesFolderUtility.AddPackageAsync( - source, + Source, new PackageIdentity(packageId, version), packageStream, globalPackagesFolder, @@ -799,6 +963,23 @@ file sealed record AnalyzerRule( file sealed record AnalyzerConfiguration(string Id, string[] Comments, DiagnosticSeverity? Severity); +// Equality comparer for (id, version) where id is case-insensitive and +// version is compared ordinally. Used to dedupe top-level package yields +// without collapsing distinct versions of the same id. +file sealed class VersionedPackageKeyComparer : IEqualityComparer<(string Id, string? Version)> +{ + public static readonly VersionedPackageKeyComparer Instance = new(); + + public bool Equals((string Id, string? Version) x, (string Id, string? Version) y) => + StringComparer.OrdinalIgnoreCase.Equals(x.Id, y.Id) && + StringComparer.Ordinal.Equals(x.Version, y.Version); + + public int GetHashCode((string Id, string? Version) obj) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Id), + obj.Version is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Version)); +} + internal static partial class Patterns { public static readonly Regex DiagnosticSeverity = MyRegex();