diff --git a/AGENTS.md b/AGENTS.md index 5305972..32e3c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,7 @@ dotnet test DesignPatterns.slnx -c Release | DP023 | 未注册工厂(Analyzer + CodeFix) | | DP024 | 未注册 Handler(Analyzer + CodeFix) | | DP025 | 未知注册表键字面量(Analyzer + CodeFix;Strategy/Factory `Get`/`TryGet`/`Create`/`TryCreate`) | +| DP033 | 跨程序集重复 strategy key(Analyzer;多供应商宿主引用冲突) | | DP026–DP031 | State 转换表(生成器;重复边、非法 enum、holder、孤立态 Info) | | DP010–DP015 | CompositePart(生成器) | | DP016–DP019 | Decorator(生成器) | diff --git a/DesignPatterns.Analyzers/AnalyzerSymbolHelper.cs b/DesignPatterns.Analyzers/AnalyzerSymbolHelper.cs index cd89184..e1f9b96 100644 --- a/DesignPatterns.Analyzers/AnalyzerSymbolHelper.cs +++ b/DesignPatterns.Analyzers/AnalyzerSymbolHelper.cs @@ -52,6 +52,13 @@ internal static bool ImplementsContract(INamedTypeSymbol typeSymbol, INamedTypeS internal static INamedTypeSymbol? TryGetContractTypeFromAttribute(AttributeData attribute) { + if (attribute.AttributeClass is INamedTypeSymbol attributeClass && + attributeClass.TypeArguments.Length == 1 && + attributeClass.TypeArguments[0] is INamedTypeSymbol typeArgumentContract) + { + return typeArgumentContract; + } + if (attribute.AttributeClass?.IsGenericType == true) { return attribute.AttributeClass.TypeArguments.Length == 1 diff --git a/DesignPatterns.Analyzers/CrossAssemblyRegistryKeyAnalyzer.cs b/DesignPatterns.Analyzers/CrossAssemblyRegistryKeyAnalyzer.cs new file mode 100644 index 0000000..5970732 --- /dev/null +++ b/DesignPatterns.Analyzers/CrossAssemblyRegistryKeyAnalyzer.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using DesignPatterns.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DesignPatterns.Analyzers; + +/// +/// Reports duplicate strategy keys for the same contract when multiple referenced provider assemblies are in the compilation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CrossAssemblyRegistryKeyAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = + DesignPatternsDiagnosticDescriptors.PluginRegistryDuplicateKeyAcrossAssemblies; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(Rule); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + private static void AnalyzeCompilation(CompilationAnalysisContext context) + { + var registrations = CollectStrategyRegistrations(context.Compilation); + if (registrations.IsEmpty) + { + return; + } + + ReportDuplicates(context, registrations); + } + + private static void ReportDuplicates( + CompilationAnalysisContext context, + ImmutableArray registrations) + { + foreach (var group in registrations.GroupBy( + static registration => ( + Contract: GetContractIdentity(registration.Contract), + registration.Key), + EqualityComparer<(string Contract, string Key)>.Default)) + { + var assemblyNames = string.Join( + ", ", + group + .Select(static registration => registration.Assembly.Name ?? string.Empty) + .Distinct(StringComparer.Ordinal) + .OrderBy(static name => name, StringComparer.Ordinal)); + + if (group.Select(static registration => registration.Assembly).Distinct(SymbolEqualityComparer.Default).Count() <= 1) + { + continue; + } + + foreach (var registration in group) + { + context.ReportDiagnostic(Diagnostic.Create( + Rule, + registration.Location, + group.Key.Key, + group.Key.Contract, + assemblyNames)); + } + } + } + + private static string GetContractIdentity(INamedTypeSymbol contract) => + contract.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + private static ImmutableArray CollectStrategyRegistrations(Compilation compilation) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var assembly in AnalyzerSymbolHelper.GetAssembliesInCompilation(compilation)) + { + foreach (var typeSymbol in AnalyzerSymbolHelper.GetAllTypes(assembly.GlobalNamespace)) + { + foreach (var attribute in typeSymbol.GetAttributes()) + { + if (!StrategyAnalysisConstants.IsRegisterStrategyAttribute(attribute.AttributeClass)) + { + continue; + } + + var contract = AnalyzerSymbolHelper.TryGetContractTypeFromAttribute(attribute); + var key = AnalyzerSymbolHelper.TryGetKeyFromAttribute(attribute); + if (contract is null || key is null) + { + continue; + } + + var location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() + ?? typeSymbol.Locations.FirstOrDefault() + ?? Location.None; + + builder.Add(new StrategyRegistration(contract, key, assembly, location)); + } + } + } + + return builder.ToImmutable(); + } + + private sealed class StrategyRegistration + { + public StrategyRegistration( + INamedTypeSymbol contract, + string key, + IAssemblySymbol assembly, + Location location) + { + Contract = contract; + Key = key; + Assembly = assembly; + Location = location; + } + + public INamedTypeSymbol Contract { get; } + + public string Key { get; } + + public IAssemblySymbol Assembly { get; } + + public Location Location { get; } + } +} diff --git a/DesignPatterns.Analyzers/StrategyAnalysisConstants.cs b/DesignPatterns.Analyzers/StrategyAnalysisConstants.cs index 5cf40c7..0b9a263 100644 --- a/DesignPatterns.Analyzers/StrategyAnalysisConstants.cs +++ b/DesignPatterns.Analyzers/StrategyAnalysisConstants.cs @@ -1,7 +1,23 @@ +using Microsoft.CodeAnalysis; + namespace DesignPatterns.Analyzers; internal static class StrategyAnalysisConstants { internal const string RegisterStrategyMetadataName = "DesignPatterns.Behavioral.RegisterStrategyAttribute"; internal const string RegisterStrategyGenericMetadataName = "DesignPatterns.Behavioral.RegisterStrategyAttribute`1"; + + internal static bool IsRegisterStrategyAttribute(INamedTypeSymbol? attributeClass) + { + if (attributeClass is null) + { + return false; + } + + return attributeClass.OriginalDefinition.MetadataName switch + { + "RegisterStrategyAttribute" or "RegisterStrategyAttribute`1" => true, + _ => false, + }; + } } diff --git a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs index a348a12..f3412e6 100644 --- a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs +++ b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs @@ -223,6 +223,14 @@ public static class DesignPatternsDiagnosticDescriptors DiagnosticSeverity.Info, AnalyzerCategory); + public static DiagnosticDescriptor PluginRegistryDuplicateKeyAcrossAssemblies { get; } = Create( + DiagnosticIds.PluginRegistryDuplicateKeyAcrossAssemblies, + "Duplicate registry key across assemblies", + "Strategy key '{0}' for contract '{1}' is registered in multiple assemblies ({2}). Use unique keys per provider assembly or reference only one provider per contract dimension.", + "Plugin provider assemblies must not register the same strategy key for the same contract when both are referenced by the host.", + DiagnosticSeverity.Error, + AnalyzerCategory); + // State transition table (DP026–DP031) public static DiagnosticDescriptor StateTransitionDuplicateEdge { get; } = Create( diff --git a/DesignPatterns.Diagnostics/DiagnosticIds.cs b/DesignPatterns.Diagnostics/DiagnosticIds.cs index 4dae704..58b6c37 100644 --- a/DesignPatterns.Diagnostics/DiagnosticIds.cs +++ b/DesignPatterns.Diagnostics/DiagnosticIds.cs @@ -30,6 +30,7 @@ public static class DiagnosticIds public const string RegisterFactoryUnregisteredImplementation = "DP023"; public const string HandlerOrderUnregisteredImplementation = "DP024"; public const string RegistryKeyNotRegistered = "DP025"; + public const string PluginRegistryDuplicateKeyAcrossAssemblies = "DP033"; public const string StateTransitionDuplicateEdge = "DP026"; public const string StateTransitionInvalidStateMember = "DP027"; public const string StateTransitionInvalidTriggerMember = "DP028"; diff --git a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md index 99887d3..4975710 100644 --- a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -27,6 +27,7 @@ DP006 | DesignPatterns.Analyzers | Info | Strategy implementation missing DP023 | DesignPatterns.Analyzers | Info | Factory implementation missing RegisterFactory attribute DP024 | DesignPatterns.Analyzers | Info | Handler implementation missing HandlerOrder attribute DP025 | DesignPatterns.Analyzers | Info | Registry key is not registered for contract +DP033 | DesignPatterns.Analyzers | Error | Duplicate strategy key across referenced provider assemblies DP026 | DesignPatterns.Generators | Error | Duplicate state transition edge DP027 | DesignPatterns.Generators | Error | Transition state is not a declared enum member DP028 | DesignPatterns.Generators | Error | Transition trigger is not a declared enum member diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 394f8a3..f588b63 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -156,7 +156,7 @@ Nuke `UnitTest` 会依次运行 `tests/` 下四个测试项目,并将 TRX 写 | 单元 | `tests/DesignPatterns.Tests` | 运行时 API、特性校验 | | 集成 | `tests/DesignPatterns.Tests/Integration/` | 生成器产出 → 运行时(Chain/Strategy/Composite/Singleton/Decorator/Factory/EventAggregator 单元测试在 Behavioral) | | 快照 | `tests/DesignPatterns.SourceGenerators.Tests` | 生成源码与 DP 诊断(Verify) | -| Analyzer | `tests/DesignPatterns.Analyzers.Tests` | DP006、DP023、DP024、DP025 与 CodeFix(Strategy/Factory/Handler/Registry key) | +| Analyzer | `tests/DesignPatterns.Analyzers.Tests` | DP006、DP023、DP024、DP025、DP033 与 CodeFix(Strategy/Factory/Handler/Registry key) | | DI 扩展 | `tests/DesignPatterns.Extensions.DependencyInjection.Tests` | DI 注册与解析 | | AppSettings 扩展 | `tests/DesignPatterns.Extensions.AppSettings.Tests`(net8.0)、`tests/DesignPatterns.Extensions.AppSettings.Tests.Net48`(net48 编译) | AppSettings 桥接 | | Configuration 扩展 | `tests/DesignPatterns.Extensions.Configuration.Tests`(net8.0)、`tests/DesignPatterns.Extensions.Configuration.Tests.Net48`(net48 编译) | `IConfiguration` 桥接 | diff --git a/docs/PluginAssemblies.md b/docs/PluginAssemblies.md index eb82bb4..8a9f181 100644 --- a/docs/PluginAssemblies.md +++ b/docs/PluginAssemblies.md @@ -119,6 +119,8 @@ FCErrorRegistry.RegisterAutofac(builder); v1 **不**提供 `[RegisterStrategyBundle]`;若样板过多再评估(见 #108)。 +同一实现需在 Autofac 中暴露多个接口时,在模块中手动补充 `builder.RegisterType().As().As()`;仅当样例摩擦证明有必要时再评估生成器 `AdditionalContracts` 参数。 + --- ## 6. 配置选型 @@ -138,7 +140,8 @@ v1 **不**提供 `[RegisterStrategyBundle]`;若样板过多再评估(见 #10 |----|------| | DP003 | 同一编译单元内重复 key(已有) | | DP025 | 未知字面量 key(已有) | -| **DP033** | 跨程序集重复 key 合并 — **暂不实现**:`partial` 无法跨程序集,重复 key 表现为多个同名 Registry 类型或宿主歧义,应在 **引用图 / 部署** 层保证单供应商维度 | +| **DP033** | 宿主同时引用多个供应商程序集时,**同一契约**出现**相同 strategy key**(Analyzer Error) | +| **DP034** | 供应商已引用但 key 未出现在样例/配置文档 — 可选 Info,**未实现** | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0de29a0..10aee1c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,7 +19,7 @@ | Factory | `{Contract}Keys`、`{Contract}Registry` | | State | `{StateEnum}TransitionTable`、partial `{Holder}` 便捷方法 | -新增生成器必须沿用此命名风格(详见 [Decorator.md](Decorator.md))。诊断 ID 续接现有区段,下一个可用 ID 为 **DP032**。 +新增生成器必须沿用此命名风格(详见 [Decorator.md](Decorator.md))。诊断 ID 续接现有区段,下一个可用 ID 为 **DP034**(DP032 预留给 App.config 静态分析,**未实现**;DP033 为跨程序集重复 strategy key)。 --- diff --git a/tests/DesignPatterns.Analyzers.Tests/CrossAssemblyRegistryKeyAnalyzerTests.cs b/tests/DesignPatterns.Analyzers.Tests/CrossAssemblyRegistryKeyAnalyzerTests.cs new file mode 100644 index 0000000..b463022 --- /dev/null +++ b/tests/DesignPatterns.Analyzers.Tests/CrossAssemblyRegistryKeyAnalyzerTests.cs @@ -0,0 +1,213 @@ +using DesignPatterns.Analyzers; + +namespace DesignPatterns.Analyzers.Tests; + +public sealed class CrossAssemblyRegistryKeyAnalyzerTests +{ + private const string HostSource = """ + namespace Plugin.Host; + + public static class Program + { + public static void Main() + { + } + } + """; + + [Fact] + public async Task ReportsDp033WhenSameStrategyKeyExistsInTwoReferencedAssemblies() + { + const string alphaProviderSource = """ + using DesignPatterns.Behavioral; + + namespace Plugin.Contracts + { + public interface ICardMotion + { + string Name { get; } + } + } + + namespace Plugin.Providers.Alpha + { + using Plugin.Contracts; + + [RegisterStrategy("alpha")] + public sealed class AlphaCard : ICardMotion + { + public string Name => "alpha"; + } + } + """; + + const string betaProviderSource = """ + using DesignPatterns.Behavioral; + + namespace Plugin.Contracts + { + public interface ICardMotion + { + string Name { get; } + } + } + + namespace Plugin.Providers.Beta + { + using Plugin.Contracts; + + [RegisterStrategy("alpha")] + public sealed class BetaCard : ICardMotion + { + public string Name => "alpha-beta"; + } + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersWithTwoReferencedAssembliesAndHostAsync( + alphaProviderSource, + betaProviderSource, + HostSource, + new CrossAssemblyRegistryKeyAnalyzer()); + + Assert.Equal(2, diagnostics.Count(diagnostic => diagnostic.Id == "DP033")); + Assert.All( + diagnostics.Where(diagnostic => diagnostic.Id == "DP033"), + diagnostic => + { + Assert.Contains("alpha", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("ICardMotion", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("ProviderAlpha", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("ProviderBeta", diagnostic.GetMessage(), StringComparison.Ordinal); + }); + } + + [Fact] + public async Task DoesNotReportDp033WhenReferencedAssembliesUseDistinctKeys() + { + const string alphaProviderSource = """ + using DesignPatterns.Behavioral; + + namespace Plugin.Contracts + { + public interface ICardMotion + { + string Name { get; } + } + } + + namespace Plugin.Providers.Alpha + { + using Plugin.Contracts; + + [RegisterStrategy("alpha")] + public sealed class AlphaCard : ICardMotion + { + public string Name => "alpha"; + } + } + """; + + const string betaProviderSource = """ + using DesignPatterns.Behavioral; + + namespace Plugin.Contracts + { + public interface ICardMotion + { + string Name { get; } + } + } + + namespace Plugin.Providers.Beta + { + using Plugin.Contracts; + + [RegisterStrategy("beta")] + public sealed class BetaCard : ICardMotion + { + public string Name => "beta"; + } + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersWithTwoReferencedAssembliesAndHostAsync( + alphaProviderSource, + betaProviderSource, + HostSource, + new CrossAssemblyRegistryKeyAnalyzer()); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP033"); + } + + [Fact] + public async Task DoesNotReportDp033WhenDuplicateKeyExistsInSingleAssembly() + { + const string source = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public interface ICardMotion + { + string Name { get; } + } + + [RegisterStrategy("alpha")] + public sealed class AlphaCard : ICardMotion + { + public string Name => "alpha"; + } + + [RegisterStrategy("alpha")] + public sealed class BetaCard : ICardMotion + { + public string Name => "beta"; + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync( + source, + new CrossAssemblyRegistryKeyAnalyzer()); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP033"); + } + + [Fact] + public async Task DoesNotReportDp033WhenSameKeyIsUsedForDifferentContracts() + { + const string source = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public interface IControl + { + void Run(); + } + + public interface IErrorHandler + { + string Describe(); + } + + [RegisterStrategy("gamma")] + public sealed class GammaControl : IControl + { + public void Run() { } + } + + [RegisterStrategy("gamma")] + public sealed class GammaError : IErrorHandler + { + public string Describe() => "gamma"; + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync( + source, + new CrossAssemblyRegistryKeyAnalyzer()); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP033"); + } +} diff --git a/tests/DesignPatterns.Analyzers.Tests/DesignPatternsDiagnosticDescriptorsTests.cs b/tests/DesignPatterns.Analyzers.Tests/DesignPatternsDiagnosticDescriptorsTests.cs index 51f7672..58de59b 100644 --- a/tests/DesignPatterns.Analyzers.Tests/DesignPatternsDiagnosticDescriptorsTests.cs +++ b/tests/DesignPatterns.Analyzers.Tests/DesignPatternsDiagnosticDescriptorsTests.cs @@ -10,6 +10,7 @@ public sealed class DesignPatternsDiagnosticDescriptorsTests [InlineData("DP006")] [InlineData("DP024")] [InlineData("DP025")] + [InlineData("DP033")] public void Help_link_uses_diagnostics_page_fragment(string diagnosticId) { var helpLink = DiagnosticHelpLinks.For(diagnosticId); @@ -38,4 +39,15 @@ public void Registry_key_descriptor_exposes_help_link_and_description() Assert.Equal(DiagnosticHelpLinks.For(descriptor.Id), descriptor.HelpLinkUri); Assert.Contains("Registered keys", descriptor.MessageFormat.ToString()); } + + [Fact] + public void Cross_assembly_registry_key_descriptor_exposes_help_link_and_description() + { + var descriptor = new CrossAssemblyRegistryKeyAnalyzer().SupportedDiagnostics[0]; + + Assert.Equal(DiagnosticIds.PluginRegistryDuplicateKeyAcrossAssemblies, descriptor.Id); + Assert.False(string.IsNullOrWhiteSpace(descriptor.Description.ToString())); + Assert.Equal(DiagnosticHelpLinks.For(descriptor.Id), descriptor.HelpLinkUri); + Assert.Contains("multiple assemblies", descriptor.MessageFormat.ToString()); + } } diff --git a/tests/DesignPatterns.Analyzers.Tests/UnregisteredStrategyAnalyzerTests.cs b/tests/DesignPatterns.Analyzers.Tests/UnregisteredStrategyAnalyzerTests.cs index 83e04b7..4524f2b 100644 --- a/tests/DesignPatterns.Analyzers.Tests/UnregisteredStrategyAnalyzerTests.cs +++ b/tests/DesignPatterns.Analyzers.Tests/UnregisteredStrategyAnalyzerTests.cs @@ -43,6 +43,22 @@ internal static async Task> RunAnalyzersWithReference .ConfigureAwait(false); } + internal static async Task> RunAnalyzersWithTwoReferencedAssembliesAndHostAsync( + string firstReferencedAssemblySource, + string secondReferencedAssemblySource, + string hostAssemblySource, + params DiagnosticAnalyzer[] analyzers) + { + var compilation = CreateCompilationWithTwoReferencedAssembliesAndHost( + firstReferencedAssemblySource, + secondReferencedAssemblySource, + hostAssemblySource); + return await compilation + .WithAnalyzers(ImmutableArray.Create(analyzers)) + .GetAnalyzerDiagnosticsAsync() + .ConfigureAwait(false); + } + internal static async Task ApplyGeneratorCodeFixAsync( string source, string diagnosticId, @@ -147,6 +163,59 @@ private static CSharpCompilation CreateCompilationWithReferencedAssembly( new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } + private static CSharpCompilation CreateCompilationWithTwoReferencedAssembliesAndHost( + string firstReferencedAssemblySource, + string secondReferencedAssemblySource, + string hostAssemblySource) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var firstReference = EmitCompilationReference( + "ProviderAlpha", + firstReferencedAssemblySource, + "Alpha.cs", + parseOptions); + var secondReference = EmitCompilationReference( + "ProviderBeta", + secondReferencedAssemblySource, + "Beta.cs", + parseOptions); + + var hostTree = CSharpSyntaxTree.ParseText( + hostAssemblySource, + parseOptions, + path: "Host.cs"); + return CSharpCompilation.Create( + "Host", + new[] { hostTree }, + References.Add(firstReference).Add(secondReference), + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + } + + private static MetadataReference EmitCompilationReference( + string assemblyName, + string source, + string fileName, + CSharpParseOptions parseOptions) + { + var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: fileName); + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { tree }, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var stream = new MemoryStream(); + var emitResult = compilation.Emit(stream); + if (!emitResult.Success) + { + throw new InvalidOperationException( + string.Join(Environment.NewLine, emitResult.Diagnostics.Select(diagnostic => diagnostic.ToString()))); + } + + stream.Position = 0; + return MetadataReference.CreateFromStream(stream); + } + private static ImmutableArray CreateReferences() { var references = new List