diff --git a/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/given/a_conceptasmusthavenotsetssentinelrule.cs b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/given/a_conceptasmusthavenotsetssentinelrule.cs new file mode 100644 index 0000000..d5db135 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/given/a_conceptasmusthavenotsetssentinelrule.cs @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_ConceptAsMustHaveNotSetSentinelRule.given; + +public class a_conceptasmusthavenotsetssentinelrule : Cratis.Architecture.CodeAnalysis.Specs.given.an_architecture_analyzer +{ +} diff --git a/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_empty_sentinel.cs b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_empty_sentinel.cs new file mode 100644 index 0000000..68c6603 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_empty_sentinel.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_ConceptAsMustHaveNotSetSentinelRule.when_analyzing_concept_records; + +public class and_concept_has_empty_sentinel : given.a_conceptasmusthavenotsetssentinelrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +public record ConceptAs(T Value); + +public record AuthorName(string Value) : ConceptAs(Value) +{ + public static readonly AuthorName Empty = new(string.Empty); +} +"""); + + [Fact] void should_not_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0027").ShouldBeFalse(); +} diff --git a/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_notset_sentinel.cs b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_notset_sentinel.cs new file mode 100644 index 0000000..ea1b856 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_has_notset_sentinel.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_ConceptAsMustHaveNotSetSentinelRule.when_analyzing_concept_records; + +public class and_concept_has_notset_sentinel : given.a_conceptasmusthavenotsetssentinelrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +using System; + +public record ConceptAs(T Value); + +public record AuthorId(Guid Value) : ConceptAs(Value) +{ + public static readonly AuthorId NotSet = new(Guid.Empty); +} +"""); + + [Fact] void should_not_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0027").ShouldBeFalse(); +} diff --git a/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_missing_notset_sentinel.cs b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_missing_notset_sentinel.cs new file mode 100644 index 0000000..9006197 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_concept_missing_notset_sentinel.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_ConceptAsMustHaveNotSetSentinelRule.when_analyzing_concept_records; + +public class and_concept_missing_notset_sentinel : given.a_conceptasmusthavenotsetssentinelrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +using System; + +public record ConceptAs(T Value); + +public record AuthorId(Guid Value) : ConceptAs(Value) +{ +} +"""); + + [Fact] void should_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0027").ShouldBeTrue(); +} diff --git a/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_regular_record_without_notset.cs b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_regular_record_without_notset.cs new file mode 100644 index 0000000..548970c --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_ConceptAsMustHaveNotSetSentinelRule/when_analyzing_concept_records/and_regular_record_without_notset.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_ConceptAsMustHaveNotSetSentinelRule.when_analyzing_concept_records; + +public class and_regular_record_without_notset : given.a_conceptasmusthavenotsetssentinelrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +public record Author(string Name) +{ +} +"""); + + [Fact] void should_not_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0027").ShouldBeFalse(); +} diff --git a/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/given/a_guidconceptmusthavenwfactoryrule.cs b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/given/a_guidconceptmusthavenwfactoryrule.cs new file mode 100644 index 0000000..3db540a --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/given/a_guidconceptmusthavenwfactoryrule.cs @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_GuidConceptMustHaveNewFactoryRule.given; + +public class a_guidconceptmusthavenwfactoryrule : Cratis.Architecture.CodeAnalysis.Specs.given.an_architecture_analyzer +{ +} diff --git a/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_has_new_factory.cs b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_has_new_factory.cs new file mode 100644 index 0000000..e8e2632 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_has_new_factory.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_GuidConceptMustHaveNewFactoryRule.when_analyzing_guid_concepts; + +public class and_guid_concept_has_new_factory : given.a_guidconceptmusthavenwfactoryrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +using System; + +public record ConceptAs(T Value); + +public record AuthorId(Guid Value) : ConceptAs(Value) +{ + public static AuthorId New() => new(Guid.NewGuid()); +} +"""); + + [Fact] void should_not_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0028").ShouldBeFalse(); +} diff --git a/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_missing_new_factory.cs b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_missing_new_factory.cs new file mode 100644 index 0000000..d500838 --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_guid_concept_missing_new_factory.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_GuidConceptMustHaveNewFactoryRule.when_analyzing_guid_concepts; + +public class and_guid_concept_missing_new_factory : given.a_guidconceptmusthavenwfactoryrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +using System; + +public record ConceptAs(T Value); + +public record AuthorId(Guid Value) : ConceptAs(Value) +{ +} +"""); + + [Fact] void should_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0028").ShouldBeTrue(); +} diff --git a/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_string_concept_without_new_factory.cs b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_string_concept_without_new_factory.cs new file mode 100644 index 0000000..e8dbe4c --- /dev/null +++ b/Source/CodeAnalysis.Specs/for_GuidConceptMustHaveNewFactoryRule/when_analyzing_guid_concepts/and_string_concept_without_new_factory.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Architecture.CodeAnalysis.Specs.for_GuidConceptMustHaveNewFactoryRule.when_analyzing_guid_concepts; + +public class and_string_concept_without_new_factory : given.a_guidconceptmusthavenwfactoryrule +{ + async Task Because() => + _diagnostics = await analyze( + """ +public record ConceptAs(T Value); + +public record AuthorName(string Value) : ConceptAs(Value) +{ +} +"""); + + [Fact] void should_not_report_diagnostic() => + _diagnostics.Any(_ => _.Id == "CRARCH0028").ShouldBeFalse(); +} diff --git a/Source/CodeAnalysis/Analyzer.cs b/Source/CodeAnalysis/Analyzer.cs index 2d535f4..bc69c3f 100644 --- a/Source/CodeAnalysis/Analyzer.cs +++ b/Source/CodeAnalysis/Analyzer.cs @@ -44,6 +44,10 @@ public partial class ArchitectureAnalyzer : DiagnosticAnalyzer LoggerMessageContainerConventionsRule.Descriptor, UseCratisFundamentalsTracesRule.Descriptor, UseCratisFundamentalsMetricsRule.Descriptor, + ConceptAsMustHaveNotSetSentinelRule.Descriptor, + GuidConceptMustHaveNewFactoryRule.Descriptor, + // TODO: CRARCH0029 - Uncomment when the rule is fully implemented + // AvoidPrimitiveTypesRule.Descriptor, ]; /// @@ -61,6 +65,10 @@ public override void Initialize(AnalysisContext context) context.RegisterSyntaxNodeAction(AnalyzeMethodDeclaration, SyntaxKind.MethodDeclaration); context.RegisterSyntaxNodeAction(AnalyzeIdentifierTypeUse, SyntaxKind.IdentifierName); context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); + // TODO: CRARCH0029 - Uncomment these when the rule is fully implemented + // context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration); + // context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration); + // context.RegisterSyntaxNodeAction(AnalyzeParameter, SyntaxKind.Parameter); context.RegisterSyntaxNodeAction( AnalyzePrivateModifier, SyntaxKind.ClassDeclaration, diff --git a/Source/CodeAnalysis/AnalyzerReleases.Unshipped.md b/Source/CodeAnalysis/AnalyzerReleases.Unshipped.md index b88f374..55d21ca 100644 --- a/Source/CodeAnalysis/AnalyzerReleases.Unshipped.md +++ b/Source/CodeAnalysis/AnalyzerReleases.Unshipped.md @@ -31,3 +31,5 @@ CRARCH0023 | Architecture | Warning | Use typed logger category CRARCH0024 | Architecture | Warning | LoggerMessage container conventions CRARCH0025 | Architecture | Warning | Use Cratis Fundamentals traces CRARCH0026 | Architecture | Warning | Use Cratis Fundamentals metrics +CRARCH0027 | Architecture | Warning | ConceptAs must have a static readonly NotSet sentinel +CRARCH0028 | Architecture | Warning | Guid-backed identity concept must have a static New() factory diff --git a/Source/CodeAnalysis/NamedTypeAnalyzer.cs b/Source/CodeAnalysis/NamedTypeAnalyzer.cs index e3e2409..68b2906 100644 --- a/Source/CodeAnalysis/NamedTypeAnalyzer.cs +++ b/Source/CodeAnalysis/NamedTypeAnalyzer.cs @@ -24,6 +24,8 @@ static void AnalyzeNamedType(SymbolAnalysisContext context) ExceptionTypeNamingRule.Analyze(context, type); NoPostfixesOnClassNamesRule.Analyze(context, type); StaticClassNamingConventionRule.Analyze(context, type); + ConceptAsMustHaveNotSetSentinelRule.Analyze(context, type); + GuidConceptMustHaveNewFactoryRule.Analyze(context, type); if (type.TypeKind != TypeKind.Class) { diff --git a/Source/CodeAnalysis/Rules/AvoidPrimitiveTypesRule.cs b/Source/CodeAnalysis/Rules/AvoidPrimitiveTypesRule.cs new file mode 100644 index 0000000..ed74250 --- /dev/null +++ b/Source/CodeAnalysis/Rules/AvoidPrimitiveTypesRule.cs @@ -0,0 +1,271 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cratis.Architecture.CodeAnalysis.Rules; + +/// +/// Rule that warns about using primitive types directly instead of wrapping them in ConceptAs. +/// +public static class AvoidPrimitiveTypesRule +{ + static readonly SpecialType[] _primitiveTypes = + [ + SpecialType.System_String, + SpecialType.System_Int32, + SpecialType.System_Int64, + SpecialType.System_Int16, + SpecialType.System_Byte, + SpecialType.System_UInt32, + SpecialType.System_UInt64, + SpecialType.System_UInt16, + SpecialType.System_SByte, + SpecialType.System_Double, + SpecialType.System_Single, + SpecialType.System_Decimal, + SpecialType.System_Boolean, + ]; + + /// + /// The diagnostic rule identifier. + /// + public const string Id = "CRARCH0029"; + + /// + /// The diagnostic descriptor for this rule. + /// + public static readonly DiagnosticDescriptor Descriptor = + DiagnosticRuleFactory.Create( + Id, + "Avoid using primitive types - wrap in ConceptAs<>", + "{0} '{1}' uses primitive type '{2}' - wrap it in a ConceptAs<{2}> record", + "Avoid using primitive types such as int, Guid, string directly in domain models, commands, events, or queries. Wrap them using ConceptAs to provide type safety and make the domain model more explicit. For example, create 'public record AuthorId(Guid Value) : ConceptAs(Value)' instead of using Guid directly."); + + /// + /// Analyzes a property and reports a diagnostic if it uses a primitive type. + /// + /// The for the current analysis. + /// The to analyze. + public static void AnalyzeProperty(SyntaxNodeAnalysisContext context, PropertyDeclarationSyntax property) + { + var semanticModel = context.SemanticModel; + var typeInfo = semanticModel.GetTypeInfo(property.Type, context.CancellationToken); + var type = typeInfo.Type; + + if (type is null) + { + return; + } + + // Check if we should warn about this property's type + if (!ShouldWarnAboutType(context, type, property.Parent?.Parent)) + { + return; + } + + var typeName = GetTypeName(type); + context.ReportDiagnostic(Diagnostic.Create(Descriptor, property.Type.GetLocation(), "Property", property.Identifier.Text, typeName)); + } + + /// + /// Analyzes a parameter and reports a diagnostic if it uses a primitive type. + /// + /// The for the current analysis. + /// The to analyze. + public static void AnalyzeParameter(SyntaxNodeAnalysisContext context, ParameterSyntax parameter) + { + if (parameter.Type is null) + { + return; + } + + var semanticModel = context.SemanticModel; + var typeInfo = semanticModel.GetTypeInfo(parameter.Type, context.CancellationToken); + var type = typeInfo.Type; + + if (type is null) + { + return; + } + + // Check if we should warn about this parameter's type + if (!ShouldWarnAboutType(context, type, parameter.Parent?.Parent?.Parent)) + { + return; + } + + var typeName = GetTypeName(type); + context.ReportDiagnostic(Diagnostic.Create(Descriptor, parameter.Type.GetLocation(), "Parameter", parameter.Identifier.Text, typeName)); + } + + /// + /// Analyzes a field and reports a diagnostic if it uses a primitive type. + /// + /// The for the current analysis. + /// The to analyze. + public static void AnalyzeField(SyntaxNodeAnalysisContext context, FieldDeclarationSyntax field) + { + var semanticModel = context.SemanticModel; + var typeInfo = semanticModel.GetTypeInfo(field.Declaration.Type, context.CancellationToken); + var type = typeInfo.Type; + + if (type is null) + { + return; + } + + // Check if we should warn about this field's type + if (!ShouldWarnAboutType(context, type, field.Parent)) + { + return; + } + + var variable = field.Declaration.Variables.FirstOrDefault(); + if (variable is null) + { + return; + } + + var typeName = GetTypeName(type); + context.ReportDiagnostic(Diagnostic.Create(Descriptor, field.Declaration.Type.GetLocation(), "Field", variable.Identifier.Text, typeName)); + } + + static bool ShouldWarnAboutType(SyntaxNodeAnalysisContext context, ITypeSymbol type, SyntaxNode? containingNode) + { + // Check if it's a primitive type or Guid + var isPrimitive = _primitiveTypes.Contains(type.SpecialType); + var isGuid = type.Name == "Guid" && type.ContainingNamespace?.ToDisplayString() == "System"; + + if (!isPrimitive && !isGuid) + { + return false; + } + + // Don't warn if we're inside a ConceptAs type itself + if (IsInsideConceptAs(context, containingNode)) + { + return false; + } + + // Don't warn if we're in a test file + if (IsTestFile(context)) + { + return false; + } + + // Warn if we're in a class/record that looks like a domain type + return IsInDomainType(context, containingNode); + } + + static bool IsInsideConceptAs(SyntaxNodeAnalysisContext context, SyntaxNode? node) + { + var typeDeclaration = node; + while (typeDeclaration != null && typeDeclaration is not TypeDeclarationSyntax) + { + typeDeclaration = typeDeclaration.Parent; + } + + if (typeDeclaration is TypeDeclarationSyntax typeDecl) + { + var semanticModel = context.SemanticModel; + var typeSymbol = semanticModel.GetDeclaredSymbol(typeDecl, context.CancellationToken); + + if (typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.BaseType is not null) + { + var baseType = namedTypeSymbol.BaseType; + if (baseType.Name == "ConceptAs" && baseType.IsGenericType) + { + return true; + } + } + } + + return false; + } + + static bool IsTestFile(SyntaxNodeAnalysisContext context) + { + var filePath = context.Node.SyntaxTree.FilePath; + return filePath.Contains(".Specs", StringComparison.OrdinalIgnoreCase) || + filePath.Contains(".Tests", StringComparison.OrdinalIgnoreCase) || + filePath.Contains("/test/", StringComparison.OrdinalIgnoreCase) || + filePath.Contains("\\test\\", StringComparison.OrdinalIgnoreCase); + } + + static bool IsInDomainType(SyntaxNodeAnalysisContext context, SyntaxNode? node) + { + var typeDeclaration = node; + while (typeDeclaration != null && typeDeclaration is not TypeDeclarationSyntax) + { + typeDeclaration = typeDeclaration.Parent; + } + + if (typeDeclaration is not TypeDeclarationSyntax typeDecl) + { + return false; + } + + var semanticModel = context.SemanticModel; + var typeSymbol = semanticModel.GetDeclaredSymbol(typeDecl, context.CancellationToken); + + if (typeSymbol is null) + { + return false; + } + + // Check for domain-related attributes by checking all attributes + var attributes = typeSymbol.GetAttributes(); + foreach (var attr in attributes) + { + var attrName = attr.AttributeClass?.Name; + if (attrName is "CommandAttribute" or "Command" or + "EventTypeAttribute" or "EventType" or + "ReadModelAttribute" or "ReadModel" or + "QueryAttribute" or "Query") + { + return true; + } + } + + // Check for typical domain type naming patterns + var typeName = typeSymbol.Name; + if (typeName.EndsWith("Command", StringComparison.Ordinal) || + typeName.EndsWith("Event", StringComparison.Ordinal) || + typeName.EndsWith("Query", StringComparison.Ordinal) || + typeName.EndsWith("ReadModel", StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + static string GetTypeName(ITypeSymbol type) + { + if (type.Name == "Guid") + { + return "Guid"; + } + + return type.SpecialType switch + { + SpecialType.System_String => "string", + SpecialType.System_Int32 => "int", + SpecialType.System_Int64 => "long", + SpecialType.System_Int16 => "short", + SpecialType.System_Byte => "byte", + SpecialType.System_UInt32 => "uint", + SpecialType.System_UInt64 => "ulong", + SpecialType.System_UInt16 => "ushort", + SpecialType.System_SByte => "sbyte", + SpecialType.System_Double => "double", + SpecialType.System_Single => "float", + SpecialType.System_Decimal => "decimal", + SpecialType.System_Boolean => "bool", + _ => type.Name + }; + } +} diff --git a/Source/CodeAnalysis/Rules/ConceptAsMustHaveNotSetSentinelRule.cs b/Source/CodeAnalysis/Rules/ConceptAsMustHaveNotSetSentinelRule.cs new file mode 100644 index 0000000..ec5a651 --- /dev/null +++ b/Source/CodeAnalysis/Rules/ConceptAsMustHaveNotSetSentinelRule.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cratis.Architecture.CodeAnalysis.Rules; + +/// +/// Rule that ensures ConceptAs records have a static readonly NotSet or Empty sentinel field. +/// +public static class ConceptAsMustHaveNotSetSentinelRule +{ + static readonly string[] _sentinelNames = ["NotSet", "Empty", "Null", "None"]; + + /// + /// The diagnostic rule identifier. + /// + public const string Id = "CRARCH0027"; + + /// + /// The diagnostic descriptor for this rule. + /// + public static readonly DiagnosticDescriptor Descriptor = + DiagnosticRuleFactory.Create( + Id, + "ConceptAs must have a static readonly NotSet sentinel", + "Record '{0}' inheriting from ConceptAs<{1}> must have a static readonly field named 'NotSet', 'Empty', 'Null', or 'None' representing a sentinel value", + "Add a static readonly sentinel field to the ConceptAs record. For Guid-backed concepts use 'public static readonly {0} NotSet = new(Guid.Empty);', for string-backed use 'public static readonly {0} NotSet = new(string.Empty);', and for numeric types use 'public static readonly {0} NotSet = new(0);'. This makes 'no value' explicit and avoids nullable reference type noise."); + + /// + /// Analyzes a named type symbol and reports a diagnostic if it inherits from ConceptAs without a NotSet sentinel. + /// + /// The for the current analysis. + /// The to analyze. + public static void Analyze(SymbolAnalysisContext context, INamedTypeSymbol type) + { + // Only analyze records + if (type.TypeKind != TypeKind.Class || !type.IsRecord) + { + return; + } + + // Check if this type inherits from ConceptAs + var baseType = type.BaseType; + if (baseType is null || baseType.Name != "ConceptAs" || !baseType.IsGenericType) + { + return; + } + + // Check if the record has a static readonly field with an acceptable sentinel name + var hasNotSetSentinel = type.GetMembers() + .OfType() + .Any(field => + field.IsStatic && + field.IsReadOnly && + _sentinelNames.Contains(field.Name, StringComparer.Ordinal)); + + if (!hasNotSetSentinel) + { + var typeArgument = baseType.TypeArguments.FirstOrDefault(); + var typeArgumentName = typeArgument?.Name ?? "T"; + context.ReportDiagnostic(Diagnostic.Create(Descriptor, type.Locations.FirstOrDefault(), type.Name, typeArgumentName)); + } + } +} diff --git a/Source/CodeAnalysis/Rules/GuidConceptMustHaveNewFactoryRule.cs b/Source/CodeAnalysis/Rules/GuidConceptMustHaveNewFactoryRule.cs new file mode 100644 index 0000000..e1eb87a --- /dev/null +++ b/Source/CodeAnalysis/Rules/GuidConceptMustHaveNewFactoryRule.cs @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cratis.Architecture.CodeAnalysis.Rules; + +/// +/// Rule that ensures Guid-backed ConceptAs records have a static New() factory method. +/// +public static class GuidConceptMustHaveNewFactoryRule +{ + /// + /// The diagnostic rule identifier. + /// + public const string Id = "CRARCH0028"; + + /// + /// The diagnostic descriptor for this rule. + /// + public static readonly DiagnosticDescriptor Descriptor = + DiagnosticRuleFactory.Create( + Id, + "Guid-backed identity concept must have a static New() factory", + "Record '{0}' inheriting from ConceptAs must have a static method named 'New()' returning the concept type", + "Add a static factory method for creating new instances: 'public static {0} New() => new(Guid.NewGuid());'. This reads better than 'new {0}(Guid.NewGuid())' and makes the intent of creating a new identity explicit."); + + /// + /// Analyzes a named type symbol and reports a diagnostic if it inherits from ConceptAs<Guid> without a New() factory. + /// + /// The for the current analysis. + /// The to analyze. + public static void Analyze(SymbolAnalysisContext context, INamedTypeSymbol type) + { + // Only analyze records + if (type.TypeKind != TypeKind.Class || !type.IsRecord) + { + return; + } + + // Check if this type inherits from ConceptAs + var baseType = type.BaseType; + if (baseType is null || baseType.Name != "ConceptAs" || !baseType.IsGenericType) + { + return; + } + + // Check if the type argument is Guid + var typeArgument = baseType.TypeArguments.FirstOrDefault(); + if (typeArgument is null) + { + return; + } + + // Guid doesn't have a special type, so check by name and namespace + if (typeArgument.Name != "Guid" || typeArgument.ContainingNamespace?.ToDisplayString() != "System") + { + return; + } + + // Check if the record has a static New() method returning the concept type + var hasNewFactory = type.GetMembers() + .OfType() + .Any(method => + method.IsStatic && + method.Name == "New" && + method.Parameters.Length == 0 && + SymbolEqualityComparer.Default.Equals(method.ReturnType, type)); + + if (!hasNewFactory) + { + context.ReportDiagnostic(Diagnostic.Create(Descriptor, type.Locations.FirstOrDefault(), type.Name)); + } + } +} diff --git a/Source/CodeAnalysis/TypeDeclarationAnalyzer.cs b/Source/CodeAnalysis/TypeDeclarationAnalyzer.cs new file mode 100644 index 0000000..3d2cfd9 --- /dev/null +++ b/Source/CodeAnalysis/TypeDeclarationAnalyzer.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Architecture.CodeAnalysis.Rules; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Cratis.Architecture.CodeAnalysis; + +/// +/// Analyzer enforcing Cratis architecture diagnostics. +/// +public partial class ArchitectureAnalyzer +{ + static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) + { + if (context.Node is PropertyDeclarationSyntax property) + { + AvoidPrimitiveTypesRule.AnalyzeProperty(context, property); + } + } + + static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) + { + if (context.Node is FieldDeclarationSyntax field) + { + AvoidPrimitiveTypesRule.AnalyzeField(context, field); + } + } + + static void AnalyzeParameter(SyntaxNodeAnalysisContext context) + { + if (context.Node is ParameterSyntax parameter) + { + AvoidPrimitiveTypesRule.AnalyzeParameter(context, parameter); + } + } +}