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();