diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cba4a..af7db08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this * **di:** `CachingBuilder.UseMemoryCache` / `UseDistributedRedis` / `UseMultiLevel` now register a single provider instance against both `ICacheProvider` (legacy) and `ICacheStore` (new). Consumers may inject either contract through 3.x. * **docs:** Per-diagnostic reference pages under `docs/diagnostics/`: `QSPEC0001.md`, `QSPEC0002.md`, `QSPEC0003.md`. Each `[Obsolete]` attribute uses these as its `UrlFormat` target so IDE quick-info links resolve directly. * **samples:** `samples/Migration/{GeoCoordinateMigration,FilterSpecMigration,CacheStoreMigration}` — three console programs showing the old usage (with the matching `#pragma warning disable QSPEC####`) followed by the new equivalent, including value-type caching and structural equality. +* **core:** `QuerySpec.Analyzers` — standalone Roslyn analyzer + code-fix package shipping `QSPEC0001` (`GeoLocation.Latitude`/`.Longitude` → `GeoCoordinate`), `QSPEC0002` (`AdvancedFilterExpression` → `FilterSpec`), and `QSPEC0003` (`ICacheProvider.GetAsync`/`SetAsync` → `ICacheStore.TryGetAsync`/`SetValueAsync`) diagnostics with one-click code fixes and Fix-All-In-Document/Project/Solution support. Targets `netstandard2.0` per Roslyn host requirements; consumed by adding `` alongside the runtime packages. The analyzers suppress themselves when the target member already carries `[Obsolete(DiagnosticId = "QSPEC####")]` so consumers see exactly one warning per call site. Closes [#151](https://github.com/AbongileBoja/QuerySpec/issues/151). ### Deprecations diff --git a/QuerySpec.sln b/QuerySpec.sln index d5bfc76..29266d1 100644 --- a/QuerySpec.sln +++ b/QuerySpec.sln @@ -37,6 +37,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterSpecMigration", "samp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CacheStoreMigration", "samples\Migration\CacheStoreMigration\CacheStoreMigration.csproj", "{649580D0-5030-4968-8110-B75F84A9CC49}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers", "src\QuerySpec.Analyzers\QuerySpec.Analyzers.csproj", "{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers.CodeFixes", "src\QuerySpec.Analyzers.CodeFixes\QuerySpec.Analyzers.CodeFixes.csproj", "{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers.Tests", "tests\QuerySpec.Analyzers.Tests\QuerySpec.Analyzers.Tests.csproj", "{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -191,6 +197,42 @@ Global {649580D0-5030-4968-8110-B75F84A9CC49}.Release|x64.Build.0 = Release|Any CPU {649580D0-5030-4968-8110-B75F84A9CC49}.Release|x86.ActiveCfg = Release|Any CPU {649580D0-5030-4968-8110-B75F84A9CC49}.Release|x86.Build.0 = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x64.Build.0 = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x86.Build.0 = Debug|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|Any CPU.Build.0 = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x64.ActiveCfg = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x64.Build.0 = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x86.ActiveCfg = Release|Any CPU + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x86.Build.0 = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x64.ActiveCfg = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x64.Build.0 = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x86.ActiveCfg = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x86.Build.0 = Debug|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|Any CPU.Build.0 = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x64.ActiveCfg = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x64.Build.0 = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x86.ActiveCfg = Release|Any CPU + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x86.Build.0 = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x64.Build.0 = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x86.ActiveCfg = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x86.Build.0 = Debug|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|Any CPU.Build.0 = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x64.ActiveCfg = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x64.Build.0 = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x86.ActiveCfg = Release|Any CPU + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -205,5 +247,8 @@ Global {86062595-0822-4A1A-8A21-57F5B8C623E0} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3} {E98B47F0-102F-470A-B40A-230D6A0C5BE7} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3} {649580D0-5030-4968-8110-B75F84A9CC49} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3} + {E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {634574EC-D5AA-4CE1-9D72-02D5AC09C10A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/src/QuerySpec.Analyzers.CodeFixes/AdvancedFilterExpressionCodeFixProvider.cs b/src/QuerySpec.Analyzers.CodeFixes/AdvancedFilterExpressionCodeFixProvider.cs new file mode 100644 index 0000000..979f945 --- /dev/null +++ b/src/QuerySpec.Analyzers.CodeFixes/AdvancedFilterExpressionCodeFixProvider.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace QuerySpec.Analyzers; + +/// +/// Code fix for QSPEC0002: rewrites new AdvancedFilterExpression { ... } as +/// new FilterSpec { ... }. The two types share property names so the object initializer +/// transfers verbatim; the new type's accessors are rather than +/// , which is the migration's central guarantee. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AdvancedFilterExpressionCodeFixProvider))] +[Shared] +public sealed class AdvancedFilterExpressionCodeFixProvider : CodeFixProvider +{ + private const string Title = "Use FilterSpec instead of AdvancedFilterExpression"; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticIds.AdvancedFilterExpressionUsage); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document + .GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + var creation = node.FirstAncestorOrSelf(); + if (creation is null) continue; + + context.RegisterCodeFix( + CodeAction.Create( + title: Title, + createChangedDocument: ct => RewriteAsync(context.Document, creation, ct), + equivalenceKey: Title), + diagnostic); + } + } + + private static async Task RewriteAsync( + Document document, + ObjectCreationExpressionSyntax creation, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) return document; + + var newTypeName = SyntaxFactory.IdentifierName("FilterSpec") + .WithTriviaFrom(creation.Type); + + var replacement = creation.WithType(newTypeName); + + var newRoot = root.ReplaceNode(creation, replacement); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/QuerySpec.Analyzers.CodeFixes/CacheProviderInvocationCodeFixProvider.cs b/src/QuerySpec.Analyzers.CodeFixes/CacheProviderInvocationCodeFixProvider.cs new file mode 100644 index 0000000..735e57b --- /dev/null +++ b/src/QuerySpec.Analyzers.CodeFixes/CacheProviderInvocationCodeFixProvider.cs @@ -0,0 +1,158 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace QuerySpec.Analyzers; + +/// +/// Code fix for QSPEC0003: rewrites cache.SetAsync<T>(...) as +/// cache.SetValueAsync<T>(...), and rewrites await cache.GetAsync<T>(key) +/// as (await cache.TryGetAsync<T>(key)).GetValueOrDefault() so the consumer keeps the +/// same null-fallback shape they had on the legacy API. +/// +/// +/// The shipping providers (MemoryCacheProvider, DistributedCacheProvider, +/// MultiLevelCache) implement both ICacheProvider and ICacheStore through 3.x, +/// so the rewritten call resolves against the same instance: no DI changes required. Consumers +/// who explicitly typed their variable as ICacheProvider will get a compile error after the +/// fix because the new method does not exist on that interface; the diagnostic message and the +/// migration sample steer them to retype as ICacheStore. We deliberately do not auto-rewrite +/// the variable declaration because that would be a cross-file edit with subtle scoping +/// implications (DI registration, downstream usages, etc.) — that judgement stays with the +/// developer. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CacheProviderInvocationCodeFixProvider))] +[Shared] +public sealed class CacheProviderInvocationCodeFixProvider : CodeFixProvider +{ + private const string Title = "Use ICacheStore instead of ICacheProvider"; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticIds.CacheProviderInvocation); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document + .GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + var memberAccess = node.FirstAncestorOrSelf(); + if (memberAccess?.Parent is not InvocationExpressionSyntax invocation) continue; + + context.RegisterCodeFix( + CodeAction.Create( + title: Title, + createChangedDocument: ct => RewriteAsync(context.Document, invocation, ct), + equivalenceKey: Title), + diagnostic); + } + } + + private static async Task RewriteAsync( + Document document, + InvocationExpressionSyntax invocation, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) return document; + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return document; + } + + var calledName = memberAccess.Name.Identifier.ValueText; + SyntaxNode? newRoot = calledName switch + { + "SetAsync" => RewriteSetAsync(root, invocation, memberAccess), + "GetAsync" => RewriteGetAsync(root, invocation, memberAccess), + _ => null, + }; + + return newRoot is null ? document : document.WithSyntaxRoot(newRoot); + } + + private static SyntaxNode RewriteSetAsync( + SyntaxNode root, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax memberAccess) + { + var renamedName = ReplaceMethodIdentifier(memberAccess.Name, "SetValueAsync"); + var newAccess = memberAccess.WithName(renamedName); + var replacement = invocation.WithExpression(newAccess); + return root.ReplaceNode(invocation, replacement); + } + + private static SyntaxNode RewriteGetAsync( + SyntaxNode root, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax memberAccess) + { + var renamedName = ReplaceMethodIdentifier(memberAccess.Name, "TryGetAsync"); + var newAccess = memberAccess.WithName(renamedName); + var renamedInvocation = invocation.WithExpression(newAccess); + + // Wrap as: (await cache.TryGetAsync(key)).GetValueOrDefault() + // If the original call site was already inside an `await` expression, replace the await + // with the parenthesised form so we keep one await and add the GetValueOrDefault hop. + if (invocation.Parent is AwaitExpressionSyntax existingAwait) + { + var awaited = existingAwait.WithExpression(renamedInvocation); + var parenthesised = SyntaxFactory.ParenthesizedExpression(awaited.WithoutTrivia()); + var dotted = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + parenthesised, + SyntaxFactory.IdentifierName("GetValueOrDefault")); + var withFallback = SyntaxFactory.InvocationExpression(dotted) + .WithTriviaFrom(existingAwait) + .WithAdditionalAnnotations(Formatter.Annotation); + return root.ReplaceNode(existingAwait, withFallback); + } + + // No surrounding await — emit a synchronous form that still composes with .Result-style + // patterns the developer may be using. We add the .GetValueOrDefault() hop via a + // continuation so the rewrite remains semantically consistent. + var awaitExpr = SyntaxFactory.AwaitExpression(renamedInvocation.WithoutTrivia()); + var paren = SyntaxFactory.ParenthesizedExpression(awaitExpr); + var member = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + paren, + SyntaxFactory.IdentifierName("GetValueOrDefault")); + var newCall = SyntaxFactory.InvocationExpression(member) + .WithTriviaFrom(invocation) + .WithAdditionalAnnotations(Formatter.Annotation); + return root.ReplaceNode(invocation, newCall); + } + + private static SimpleNameSyntax ReplaceMethodIdentifier(SimpleNameSyntax original, string newName) + { + if (original is GenericNameSyntax generic) + { + return generic.WithIdentifier(SyntaxFactory.Identifier(newName)); + } + + if (original is IdentifierNameSyntax identifier) + { + return identifier.WithIdentifier(SyntaxFactory.Identifier(newName)); + } + + return SyntaxFactory.IdentifierName(newName).WithTriviaFrom(original); + } +} diff --git a/src/QuerySpec.Analyzers.CodeFixes/GeoLocationMemberAccessCodeFixProvider.cs b/src/QuerySpec.Analyzers.CodeFixes/GeoLocationMemberAccessCodeFixProvider.cs new file mode 100644 index 0000000..8c9ecd4 --- /dev/null +++ b/src/QuerySpec.Analyzers.CodeFixes/GeoLocationMemberAccessCodeFixProvider.cs @@ -0,0 +1,81 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace QuerySpec.Analyzers; + +/// +/// Code fix for QSPEC0001: rewrites legacy.Latitude as legacy.ToGeoCoordinate().Latitude +/// (and likewise for Longitude). The migration helper is intent-revealing, allocation-free +/// (GeoCoordinate is a ), +/// and validates inputs. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(GeoLocationMemberAccessCodeFixProvider))] +[Shared] +public sealed class GeoLocationMemberAccessCodeFixProvider : CodeFixProvider +{ + private const string Title = "Use GeoCoordinate instead of GeoLocation"; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticIds.GeoLocationMemberAccess); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document + .GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + var nameSyntax = root.FindNode(diagnostic.Location.SourceSpan); + var memberAccess = nameSyntax.FirstAncestorOrSelf(); + if (memberAccess is null) continue; + + context.RegisterCodeFix( + CodeAction.Create( + title: Title, + createChangedDocument: ct => RewriteAsync(context.Document, memberAccess, ct), + equivalenceKey: Title), + diagnostic); + } + } + + private static async Task RewriteAsync( + Document document, + MemberAccessExpressionSyntax memberAccess, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) return document; + + var receiver = memberAccess.Expression; + var memberName = memberAccess.Name; + + var helperCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver.WithoutTrivia(), + SyntaxFactory.IdentifierName("ToGeoCoordinate"))); + + var replacement = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + helperCall, + memberName.WithoutTrivia()) + .WithTriviaFrom(memberAccess); + + var newRoot = root.ReplaceNode(memberAccess, replacement); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/QuerySpec.Analyzers.CodeFixes/QuerySpec.Analyzers.CodeFixes.csproj b/src/QuerySpec.Analyzers.CodeFixes/QuerySpec.Analyzers.CodeFixes.csproj new file mode 100644 index 0000000..101de58 --- /dev/null +++ b/src/QuerySpec.Analyzers.CodeFixes/QuerySpec.Analyzers.CodeFixes.csproj @@ -0,0 +1,31 @@ + + + + + netstandard2.0 + + + QuerySpec.Analyzers + QuerySpec.Analyzers.CodeFixes + + + false + true + + + false + false + + false + + + + + + + + + + + + diff --git a/src/QuerySpec.Analyzers/AdvancedFilterExpressionAnalyzer.cs b/src/QuerySpec.Analyzers/AdvancedFilterExpressionAnalyzer.cs new file mode 100644 index 0000000..7959e73 --- /dev/null +++ b/src/QuerySpec.Analyzers/AdvancedFilterExpressionAnalyzer.cs @@ -0,0 +1,106 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace QuerySpec.Analyzers; + +/// +/// Reports (QSPEC0002) on +/// every new AdvancedFilterExpression(...) and on every type reference in a variable or +/// parameter declaration that names the deprecated POCO. +/// +/// +/// Two registrations: catches the canonical +/// new AdvancedFilterExpression { ... } migration site, while +/// and cover +/// declared usages so the diagnostic also surfaces on holders that cross API boundaries. Round-trip +/// helpers in the QuerySpec.Core.Advanced namespace itself (FilterSpec.FromMutable / +/// ToMutable) emit on consumer code; the source itself is silenced via +/// WarningsNotAsErrors in the QuerySpec.Core Directory.Build.props. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AdvancedFilterExpressionAnalyzer : DiagnosticAnalyzer +{ + private const string AdvancedFilterExpressionFullName = + "QuerySpec.Core.Advanced.AdvancedFilterExpression"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.AdvancedFilterExpressionUsage); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) return; + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(static compilationStart => + { + var legacy = compilationStart.Compilation.GetTypeByMetadataName( + AdvancedFilterExpressionFullName); + if (legacy is null) + { + return; + } + + compilationStart.RegisterSyntaxNodeAction( + ctx => AnalyzeObjectCreation(ctx, legacy), + SyntaxKind.ObjectCreationExpression); + }); + } + + private static void AnalyzeObjectCreation( + SyntaxNodeAnalysisContext context, + INamedTypeSymbol legacyType) + { + var creation = (ObjectCreationExpressionSyntax)context.Node; + var typeInfo = context.SemanticModel.GetTypeInfo(creation, context.CancellationToken); + if (typeInfo.Type is not INamedTypeSymbol named) + { + return; + } + + if (!SymbolEqualityComparer.Default.Equals(named, legacyType)) + { + return; + } + + if (HasMatchingObsoleteAttribute(named)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.AdvancedFilterExpressionUsage, + creation.Type.GetLocation())); + } + + private static bool HasMatchingObsoleteAttribute(ISymbol symbol) + { + foreach (var attribute in symbol.GetAttributes()) + { + var className = attribute.AttributeClass?.Name; + if (className is null + || !string.Equals(className, "ObsoleteAttribute", System.StringComparison.Ordinal)) + { + continue; + } + + foreach (var named in attribute.NamedArguments) + { + if (string.Equals(named.Key, "DiagnosticId", System.StringComparison.Ordinal) + && named.Value.Value is string id + && string.Equals(id, DiagnosticIds.AdvancedFilterExpressionUsage, System.StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/QuerySpec.Analyzers/AnalyzerReleases.Shipped.md b/src/QuerySpec.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/QuerySpec.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/QuerySpec.Analyzers/AnalyzerReleases.Unshipped.md b/src/QuerySpec.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..830ec9c --- /dev/null +++ b/src/QuerySpec.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,10 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +-----------|----------------------|----------|------------------------------------------------------------ +QSPEC0001 | QuerySpec.Migration | Warning | Use GeoCoordinate instead of GeoLocation.Latitude/.Longitude +QSPEC0002 | QuerySpec.Migration | Warning | Use FilterSpec instead of AdvancedFilterExpression +QSPEC0003 | QuerySpec.Migration | Warning | Use ICacheStore instead of ICacheProvider GetAsync/SetAsync diff --git a/src/QuerySpec.Analyzers/CacheProviderInvocationAnalyzer.cs b/src/QuerySpec.Analyzers/CacheProviderInvocationAnalyzer.cs new file mode 100644 index 0000000..2ece6f5 --- /dev/null +++ b/src/QuerySpec.Analyzers/CacheProviderInvocationAnalyzer.cs @@ -0,0 +1,213 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace QuerySpec.Analyzers; + +/// +/// Reports (QSPEC0003) on every +/// invocation of ICacheProvider.GetAsync<T> or ICacheProvider.SetAsync<T>. +/// +/// +/// Resolution uses on the called member's containing type so +/// the diagnostic fires whether the receiver is typed as ICacheProvider or as one of its +/// shipping implementations (MemoryCacheProvider, DistributedCacheProvider, +/// MultiLevelCache). Concrete-receiver call sites still resolve to the interface member on +/// the explicit-interface implementation path, but the analyzer also handles the +/// non-explicit-interface case by climbing IMethodSymbol.ContainingType until it +/// reaches an ITypeSymbol.AllInterfaces entry that matches. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CacheProviderInvocationAnalyzer : DiagnosticAnalyzer +{ + private const string CacheProviderFullName = "QuerySpec.Core.Caching.ICacheProvider"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.CacheProviderInvocation); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) return; + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(static compilationStart => + { + var cacheProvider = compilationStart.Compilation.GetTypeByMetadataName(CacheProviderFullName); + if (cacheProvider is null) + { + return; + } + + compilationStart.RegisterSyntaxNodeAction( + ctx => AnalyzeInvocation(ctx, cacheProvider), + SyntaxKind.InvocationExpression); + }); + } + + private static void AnalyzeInvocation( + SyntaxNodeAnalysisContext context, + INamedTypeSymbol cacheProviderType) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return; + } + + var name = memberAccess.Name.Identifier.ValueText; + if (!string.Equals(name, "GetAsync", System.StringComparison.Ordinal) + && !string.Equals(name, "SetAsync", System.StringComparison.Ordinal)) + { + return; + } + + if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol + is not IMethodSymbol method) + { + return; + } + + if (!IsCacheProviderMember(method, cacheProviderType)) + { + return; + } + + if (HasMatchingObsoleteAttribute(method)) + { + return; + } + + var replacement = string.Equals(name, "GetAsync", System.StringComparison.Ordinal) + ? "TryGetAsync" + : "SetValueAsync"; + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.CacheProviderInvocation, + memberAccess.Name.Identifier.GetLocation(), + name, + replacement)); + } + + private static bool HasMatchingObsoleteAttribute(IMethodSymbol method) + { + foreach (var candidate in EnumerateCandidateMembers(method)) + { + foreach (var attribute in candidate.GetAttributes()) + { + var className = attribute.AttributeClass?.Name; + if (className is null + || !string.Equals(className, "ObsoleteAttribute", System.StringComparison.Ordinal)) + { + continue; + } + + foreach (var named in attribute.NamedArguments) + { + if (string.Equals(named.Key, "DiagnosticId", System.StringComparison.Ordinal) + && named.Value.Value is string id + && string.Equals(id, DiagnosticIds.CacheProviderInvocation, System.StringComparison.Ordinal)) + { + return true; + } + } + } + } + + return false; + } + + private static System.Collections.Generic.IEnumerable EnumerateCandidateMembers(IMethodSymbol method) + { + yield return method; + + foreach (var implemented in method.ExplicitInterfaceImplementations) + { + yield return implemented; + } + + var containing = method.ContainingType; + if (containing is null) + { + yield break; + } + + foreach (var iface in containing.AllInterfaces) + { + foreach (var member in iface.GetMembers(method.Name)) + { + if (member is not IMethodSymbol interfaceMethod) + { + continue; + } + + var implementor = containing.FindImplementationForInterfaceMember(interfaceMethod); + if (SymbolEqualityComparer.Default.Equals(implementor, method)) + { + yield return interfaceMethod; + } + } + } + } + + private static bool IsCacheProviderMember(IMethodSymbol method, INamedTypeSymbol cacheProviderType) + { + var containing = method.ContainingType; + if (containing is null) + { + return false; + } + + if (SymbolEqualityComparer.Default.Equals(containing.OriginalDefinition, cacheProviderType)) + { + return true; + } + + if (method.IsExtensionMethod) + { + return false; + } + + foreach (var implemented in method.ExplicitInterfaceImplementations) + { + if (SymbolEqualityComparer.Default.Equals(implemented.ContainingType.OriginalDefinition, cacheProviderType)) + { + return true; + } + } + + var methodDefinition = method.OriginalDefinition; + foreach (var iface in containing.AllInterfaces) + { + if (!SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, cacheProviderType)) + { + continue; + } + + foreach (var member in iface.GetMembers(method.Name)) + { + if (member is not IMethodSymbol interfaceMethod) + { + continue; + } + + var implementor = containing.FindImplementationForInterfaceMember(interfaceMethod); + if (implementor is null) + { + continue; + } + + if (SymbolEqualityComparer.Default.Equals(implementor.OriginalDefinition, methodDefinition)) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/QuerySpec.Analyzers/DiagnosticDescriptors.cs b/src/QuerySpec.Analyzers/DiagnosticDescriptors.cs new file mode 100644 index 0000000..c7a97ea --- /dev/null +++ b/src/QuerySpec.Analyzers/DiagnosticDescriptors.cs @@ -0,0 +1,67 @@ +using Microsoft.CodeAnalysis; + +namespace QuerySpec.Analyzers; + +/// +/// Shared instances for every QuerySpec migration diagnostic. +/// Descriptors are singletons so the analyzer +/// host can fingerprint them across compilations; do not allocate new descriptors per +/// ReportDiagnostic call. +/// +public static class DiagnosticDescriptors +{ + private const string Category = "QuerySpec.Migration"; + + private const string HelpLinkBase = + "https://github.com/AbongileBoja/QuerySpec/blob/main/docs/diagnostics/"; + + /// + /// QSPEC0001 — GeoLocation.Latitude / GeoLocation.Longitude member access. + /// + public static readonly DiagnosticDescriptor GeoLocationMemberAccess = new( + id: DiagnosticIds.GeoLocationMemberAccess, + title: "Use GeoCoordinate instead of GeoLocation.Latitude/.Longitude", + messageFormat: "'GeoLocation.{0}' is deprecated; use 'GeoCoordinate' (call 'ToGeoCoordinate()' to migrate)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: + "GeoLocation exposes decimal latitude/longitude that contradicts every spatial library on .NET. " + + "GeoCoordinate is an immutable readonly record struct with double components, validated " + + "construction, and ISO 6709 round-trip. Will be removed in QuerySpec 4.0.", + helpLinkUri: HelpLinkBase + DiagnosticIds.GeoLocationMemberAccess + ".md"); + + /// + /// QSPEC0002 — usage of the mutable POCO AdvancedFilterExpression. + /// + public static readonly DiagnosticDescriptor AdvancedFilterExpressionUsage = new( + id: DiagnosticIds.AdvancedFilterExpressionUsage, + title: "Use FilterSpec instead of AdvancedFilterExpression", + messageFormat: "'AdvancedFilterExpression' is deprecated; use 'FilterSpec' (immutable record with init accessors)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: + "AdvancedFilterExpression is a mutable POCO that leaks aliasing into compiled-expression " + + "caches and forces defensive copies for safe sharing. FilterSpec is a sealed record with " + + "init-only accessors, structural value equality, and the same Validate/ComputeStableHash " + + "semantics. Will be removed in QuerySpec 4.0.", + helpLinkUri: HelpLinkBase + DiagnosticIds.AdvancedFilterExpressionUsage + ".md"); + + /// + /// QSPEC0003 — invocation of ICacheProvider.GetAsync / SetAsync. + /// + public static readonly DiagnosticDescriptor CacheProviderInvocation = new( + id: DiagnosticIds.CacheProviderInvocation, + title: "Use ICacheStore instead of ICacheProvider GetAsync/SetAsync", + messageFormat: "'ICacheProvider.{0}' is deprecated; use 'ICacheStore.{1}' (no class constraint, value-type-friendly)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: + "ICacheProvider.GetAsync/SetAsync require 'where T : class', excluding value " + + "types and producing ambiguous null-on-miss reads. ICacheStore.TryGetAsync returns " + + "CacheResult whose HasValue flag distinguishes a hit on default(T) from a miss. " + + "Will be removed in QuerySpec 4.0.", + helpLinkUri: HelpLinkBase + DiagnosticIds.CacheProviderInvocation + ".md"); +} diff --git a/src/QuerySpec.Analyzers/DiagnosticIds.cs b/src/QuerySpec.Analyzers/DiagnosticIds.cs new file mode 100644 index 0000000..2490297 --- /dev/null +++ b/src/QuerySpec.Analyzers/DiagnosticIds.cs @@ -0,0 +1,28 @@ +namespace QuerySpec.Analyzers; + +/// +/// Stable diagnostic identifiers emitted by the QuerySpec analyzer package. The IDs are part of +/// the package contract: changing one is a breaking change because it invalidates every +/// #pragma warning disable, NoWarn, and EditorConfig entry in consumer code. +/// +public static class DiagnosticIds +{ + /// + /// QSPEC0001: a member access reads GeoLocation.Latitude or + /// GeoLocation.Longitude. Migrate to GeoCoordinate. + /// + public const string GeoLocationMemberAccess = "QSPEC0001"; + + /// + /// QSPEC0002: a type reference, object creation, or member access targets + /// AdvancedFilterExpression. Migrate to FilterSpec. + /// + public const string AdvancedFilterExpressionUsage = "QSPEC0002"; + + /// + /// QSPEC0003: an invocation calls ICacheProvider.GetAsync<T> or + /// ICacheProvider.SetAsync<T>. Migrate to + /// ICacheStore.TryGetAsync<T> / SetValueAsync<T>. + /// + public const string CacheProviderInvocation = "QSPEC0003"; +} diff --git a/src/QuerySpec.Analyzers/GeoLocationMemberAccessAnalyzer.cs b/src/QuerySpec.Analyzers/GeoLocationMemberAccessAnalyzer.cs new file mode 100644 index 0000000..038e7ca --- /dev/null +++ b/src/QuerySpec.Analyzers/GeoLocationMemberAccessAnalyzer.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace QuerySpec.Analyzers; + +/// +/// Reports (QSPEC0001) on every read +/// of QuerySpec.Core.Advanced.GeoLocation.Latitude or .Longitude when the legacy +/// member is not already carrying an with the matching +/// DiagnosticId. +/// +/// +/// Detection runs against the semantic model — never against syntax text — so using +/// aliases, fully-qualified usages, and inherited member access all resolve to the same symbol +/// comparison. The analyzer scopes its registration to +/// only, so per-compilation cost is bounded by that node count rather than by every syntax node. +/// In normal QuerySpec consumption the property already carries +/// [Obsolete(DiagnosticId = "QSPEC0001")], so the compiler emits the diagnostic and the +/// analyzer suppresses the duplicate; this analyzer remains the backstop for forks or pre-3.1 +/// copies of the type and is the registration point the QSPEC0001 code fix targets. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GeoLocationMemberAccessAnalyzer : DiagnosticAnalyzer +{ + private const string GeoLocationFullName = "QuerySpec.Core.Advanced.GeoLocation"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.GeoLocationMemberAccess); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) return; + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(static compilationStart => + { + var geoLocation = compilationStart.Compilation.GetTypeByMetadataName(GeoLocationFullName); + if (geoLocation is null) + { + return; + } + + compilationStart.RegisterSyntaxNodeAction( + ctx => Analyze(ctx, geoLocation), + SyntaxKind.SimpleMemberAccessExpression); + }); + } + + private static void Analyze(SyntaxNodeAnalysisContext context, INamedTypeSymbol geoLocationType) + { + var memberAccess = (MemberAccessExpressionSyntax)context.Node; + var name = memberAccess.Name.Identifier.ValueText; + + if (!string.Equals(name, "Latitude", System.StringComparison.Ordinal) + && !string.Equals(name, "Longitude", System.StringComparison.Ordinal)) + { + return; + } + + var symbol = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken).Symbol; + if (symbol is not IPropertySymbol property) + { + return; + } + + if (!SymbolEqualityComparer.Default.Equals(property.ContainingType, geoLocationType)) + { + return; + } + + if (HasMatchingObsoleteAttribute(property)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GeoLocationMemberAccess, + memberAccess.Name.GetLocation(), + name)); + } + + private static bool HasMatchingObsoleteAttribute(ISymbol symbol) + { + foreach (var attribute in symbol.GetAttributes()) + { + var className = attribute.AttributeClass?.Name; + if (className is null + || !string.Equals(className, "ObsoleteAttribute", System.StringComparison.Ordinal)) + { + continue; + } + + foreach (var named in attribute.NamedArguments) + { + if (string.Equals(named.Key, "DiagnosticId", System.StringComparison.Ordinal) + && named.Value.Value is string id + && string.Equals(id, DiagnosticIds.GeoLocationMemberAccess, System.StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/QuerySpec.Analyzers/PublicAPI.Shipped.txt b/src/QuerySpec.Analyzers/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/QuerySpec.Analyzers/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/QuerySpec.Analyzers/PublicAPI.Unshipped.txt b/src/QuerySpec.Analyzers/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..f19840c --- /dev/null +++ b/src/QuerySpec.Analyzers/PublicAPI.Unshipped.txt @@ -0,0 +1,21 @@ +#nullable enable +QuerySpec.Analyzers.DiagnosticIds +QuerySpec.Analyzers.DiagnosticDescriptors +QuerySpec.Analyzers.GeoLocationMemberAccessAnalyzer +QuerySpec.Analyzers.GeoLocationMemberAccessAnalyzer.GeoLocationMemberAccessAnalyzer() -> void +override QuerySpec.Analyzers.GeoLocationMemberAccessAnalyzer.Initialize(Microsoft.CodeAnalysis.Diagnostics.AnalysisContext! context) -> void +override QuerySpec.Analyzers.GeoLocationMemberAccessAnalyzer.SupportedDiagnostics.get -> System.Collections.Immutable.ImmutableArray +QuerySpec.Analyzers.AdvancedFilterExpressionAnalyzer +QuerySpec.Analyzers.AdvancedFilterExpressionAnalyzer.AdvancedFilterExpressionAnalyzer() -> void +override QuerySpec.Analyzers.AdvancedFilterExpressionAnalyzer.Initialize(Microsoft.CodeAnalysis.Diagnostics.AnalysisContext! context) -> void +override QuerySpec.Analyzers.AdvancedFilterExpressionAnalyzer.SupportedDiagnostics.get -> System.Collections.Immutable.ImmutableArray +QuerySpec.Analyzers.CacheProviderInvocationAnalyzer +QuerySpec.Analyzers.CacheProviderInvocationAnalyzer.CacheProviderInvocationAnalyzer() -> void +override QuerySpec.Analyzers.CacheProviderInvocationAnalyzer.Initialize(Microsoft.CodeAnalysis.Diagnostics.AnalysisContext! context) -> void +override QuerySpec.Analyzers.CacheProviderInvocationAnalyzer.SupportedDiagnostics.get -> System.Collections.Immutable.ImmutableArray +const QuerySpec.Analyzers.DiagnosticIds.GeoLocationMemberAccess = "QSPEC0001" -> string! +const QuerySpec.Analyzers.DiagnosticIds.AdvancedFilterExpressionUsage = "QSPEC0002" -> string! +const QuerySpec.Analyzers.DiagnosticIds.CacheProviderInvocation = "QSPEC0003" -> string! +QuerySpec.Analyzers.DiagnosticDescriptors.GeoLocationMemberAccess -> Microsoft.CodeAnalysis.DiagnosticDescriptor! +QuerySpec.Analyzers.DiagnosticDescriptors.AdvancedFilterExpressionUsage -> Microsoft.CodeAnalysis.DiagnosticDescriptor! +QuerySpec.Analyzers.DiagnosticDescriptors.CacheProviderInvocation -> Microsoft.CodeAnalysis.DiagnosticDescriptor! diff --git a/src/QuerySpec.Analyzers/QuerySpec.Analyzers.csproj b/src/QuerySpec.Analyzers/QuerySpec.Analyzers.csproj new file mode 100644 index 0000000..0153cf2 --- /dev/null +++ b/src/QuerySpec.Analyzers/QuerySpec.Analyzers.csproj @@ -0,0 +1,60 @@ + + + + netstandard2.0 + + + QuerySpec.Analyzers + QuerySpec Analyzers + Roslyn analyzers and code fixes for the QuerySpec migration window. Ships QSPEC0001 (GeoLocation -> GeoCoordinate), QSPEC0002 (AdvancedFilterExpression -> FilterSpec), and QSPEC0003 (ICacheProvider -> ICacheStore) diagnostics with one-click code fixes and Fix-All-In-Document/Project/Solution support. + queryspec;analyzers;roslyn;codefix;migration;dotnet + + true + true + false + true + + true + QuerySpec.Analyzers + QuerySpec.Analyzers + false + false + + false + false + + + + + + + + + + + + + + + + + + + <_CodeFixDll>$(MSBuildThisFileDirectory)..\QuerySpec.Analyzers.CodeFixes\bin\$(Configuration)\netstandard2.0\QuerySpec.Analyzers.CodeFixes.dll + + + + + + + + + diff --git a/src/QuerySpec.Analyzers/icon.png b/src/QuerySpec.Analyzers/icon.png new file mode 100644 index 0000000..03435cb Binary files /dev/null and b/src/QuerySpec.Analyzers/icon.png differ diff --git a/tests/QuerySpec.Analyzers.Tests/AdvancedFilterExpressionAnalyzerTests.cs b/tests/QuerySpec.Analyzers.Tests/AdvancedFilterExpressionAnalyzerTests.cs new file mode 100644 index 0000000..a033104 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/AdvancedFilterExpressionAnalyzerTests.cs @@ -0,0 +1,202 @@ +using System.Threading.Tasks; +using QuerySpec.Analyzers.Tests.Verifiers; +using Xunit; +using VerifyAnalyzer = QuerySpec.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; +using VerifyCodeFix = QuerySpec.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace QuerySpec.Analyzers.Tests; + +public sealed class AdvancedFilterExpressionAnalyzerTests +{ + private static readonly string[] StubSources = { TestStubs.FilterTypes }; + + [Fact] + public async Task ObjectCreation_With_Initializer_Triggers_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() => new {|#0:AdvancedFilterExpression|} + { + Field = "Status", + Operator = FilterOperator.Equal, + Value = "Active", + }; + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage) + .WithLocation(0); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task ObjectCreation_With_Default_Constructor_Triggers_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() => new {|#0:AdvancedFilterExpression|}(); + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage) + .WithLocation(0); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task ObjectCreation_Of_FilterSpec_Does_Not_Trigger() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() => new FilterSpec + { + Field = "Status", + Operator = FilterOperator.Equal, + Value = "Active", + }; + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task Suppression_Pragma_Silences_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() + { + #pragma warning disable QSPEC0002 + return new AdvancedFilterExpression { Field = "X" }; + #pragma warning restore QSPEC0002 + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task Analyzer_Suppresses_When_Type_Already_Has_Matching_Obsolete_DiagnosticId() + { + const string ObsoleteStub = """ + using System; + + namespace QuerySpec.Core.Advanced + { + [Obsolete("Use FilterSpec", DiagnosticId = "QSPEC0002")] + public class AdvancedFilterExpression + { + public string Field { get; set; } = ""; + } + } + """; + const string Source = """ + class C + { + #pragma warning disable QSPEC0002 + object M() => new QuerySpec.Core.Advanced.AdvancedFilterExpression(); + #pragma warning restore QSPEC0002 + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, new[] { ObsoleteStub }); + } + + [Fact] + public void HelpLinkUri_Points_At_GitHub_Diagnostic_Reference() + { + var descriptor = DiagnosticDescriptors.AdvancedFilterExpressionUsage; + Assert.Equal( + "https://github.com/AbongileBoja/QuerySpec/blob/main/docs/diagnostics/QSPEC0002.md", + descriptor.HelpLinkUri); + Assert.Equal("QSPEC0002", descriptor.Id); + } + + [Fact] + public async Task CodeFix_Rewrites_AdvancedFilterExpression_To_FilterSpec() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() => new {|#0:AdvancedFilterExpression|} + { + Field = "Status", + Operator = FilterOperator.Equal, + Value = "Active", + }; + } + """; + + const string Fixed = """ + using QuerySpec.Core.Advanced; + + class C + { + object M() => new FilterSpec + { + Field = "Status", + Operator = FilterOperator.Equal, + Value = "Active", + }; + } + """; + + var expected = VerifyCodeFix.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage) + .WithLocation(0); + + await VerifyCodeFix.VerifyCodeFixAsync(Source, new[] { expected }, Fixed, StubSources); + } + + [Fact] + public async Task FixAll_Rewrites_Multiple_Object_Creations_In_Document() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + object A() => new {|#0:AdvancedFilterExpression|} { Field = "A" }; + object B() => new {|#1:AdvancedFilterExpression|} { Field = "B" }; + object D() => new {|#2:AdvancedFilterExpression|}(); + } + """; + + const string Fixed = """ + using QuerySpec.Core.Advanced; + + class C + { + object A() => new FilterSpec { Field = "A" }; + object B() => new FilterSpec { Field = "B" }; + object D() => new FilterSpec(); + } + """; + + var diagnostics = new[] + { + VerifyCodeFix.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage).WithLocation(0), + VerifyCodeFix.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage).WithLocation(1), + VerifyCodeFix.Diagnostic(DiagnosticIds.AdvancedFilterExpressionUsage).WithLocation(2), + }; + + await VerifyCodeFix.VerifyCodeFixAsync(Source, diagnostics, Fixed, StubSources); + } +} diff --git a/tests/QuerySpec.Analyzers.Tests/CacheProviderInvocationAnalyzerTests.cs b/tests/QuerySpec.Analyzers.Tests/CacheProviderInvocationAnalyzerTests.cs new file mode 100644 index 0000000..b9f9274 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/CacheProviderInvocationAnalyzerTests.cs @@ -0,0 +1,274 @@ +using System.Threading.Tasks; +using QuerySpec.Analyzers.Tests.Verifiers; +using Xunit; +using VerifyAnalyzer = QuerySpec.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; +using VerifyCodeFix = QuerySpec.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace QuerySpec.Analyzers.Tests; + +public sealed class CacheProviderInvocationAnalyzerTests +{ + private static readonly string[] StubSources = { TestStubs.CacheTypes }; + + [Fact] + public async Task GetAsync_On_ICacheProvider_Triggers_Diagnostic() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(ICacheProvider cache) + { + return await cache.{|#0:GetAsync|}("k"); + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.CacheProviderInvocation) + .WithLocation(0) + .WithArguments("GetAsync", "TryGetAsync"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task SetAsync_On_ICacheProvider_Triggers_Diagnostic() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(ICacheProvider cache) + { + await cache.{|#0:SetAsync|}("k", "v"); + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.CacheProviderInvocation) + .WithLocation(0) + .WithArguments("SetAsync", "SetValueAsync"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task TryGetAsync_On_ICacheStore_Does_Not_Trigger() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(ICacheStore store) + { + var hit = await store.TryGetAsync("k"); + await store.SetValueAsync("k", 1); + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task GetAsync_On_Unrelated_Type_Does_Not_Trigger() + { + const string Source = """ + using System.Threading.Tasks; + + interface IOther + { + Task GetAsync(string key) where T : class; + } + + class C + { + async Task M(IOther o) => await o.GetAsync("k"); + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source); + } + + [Fact] + public async Task Suppression_Pragma_Silences_Diagnostic() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(ICacheProvider cache) + { + #pragma warning disable QSPEC0003 + return await cache.GetAsync("k"); + #pragma warning restore QSPEC0003 + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task Analyzer_Suppresses_When_Method_Already_Has_Matching_Obsolete_DiagnosticId() + { + const string ObsoleteStub = """ + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace QuerySpec.Core.Caching + { + public interface ICacheProvider + { + [Obsolete("Use ICacheStore.TryGetAsync", DiagnosticId = "QSPEC0003")] + ValueTask GetAsync(string key, CancellationToken cancellationToken = default) where T : class; + } + } + """; + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + #pragma warning disable QSPEC0003 + async Task M(ICacheProvider cache) + => await cache.GetAsync("k"); + #pragma warning restore QSPEC0003 + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, new[] { ObsoleteStub }); + } + + [Fact] + public void HelpLinkUri_Points_At_GitHub_Diagnostic_Reference() + { + var descriptor = DiagnosticDescriptors.CacheProviderInvocation; + Assert.Equal( + "https://github.com/AbongileBoja/QuerySpec/blob/main/docs/diagnostics/QSPEC0003.md", + descriptor.HelpLinkUri); + Assert.Equal("QSPEC0003", descriptor.Id); + } + + [Fact] + public async Task CodeFix_Rewrites_SetAsync_To_SetValueAsync() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(TestCacheProvider cache) + { + await cache.{|#0:SetAsync|}("k", "v"); + } + } + """; + + const string Fixed = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(TestCacheProvider cache) + { + await cache.SetValueAsync("k", "v"); + } + } + """; + + var expected = VerifyCodeFix.Diagnostic(DiagnosticIds.CacheProviderInvocation) + .WithLocation(0) + .WithArguments("SetAsync", "SetValueAsync"); + + await VerifyCodeFix.VerifyCodeFixAsync(Source, new[] { expected }, Fixed, StubSources); + } + + [Fact] + public async Task CodeFix_Rewrites_GetAsync_With_Await_To_TryGetAsync_Plus_GetValueOrDefault() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(TestCacheProvider cache) + { + return await cache.{|#0:GetAsync|}("k"); + } + } + """; + + const string Fixed = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task M(TestCacheProvider cache) + { + return (await cache.TryGetAsync("k")).GetValueOrDefault(); + } + } + """; + + var expected = VerifyCodeFix.Diagnostic(DiagnosticIds.CacheProviderInvocation) + .WithLocation(0) + .WithArguments("GetAsync", "TryGetAsync"); + + await VerifyCodeFix.VerifyCodeFixAsync(Source, new[] { expected }, Fixed, StubSources); + } + + [Fact] + public async Task FixAll_Rewrites_Multiple_Cache_Calls_In_Document() + { + const string Source = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task Multi(TestCacheProvider cache) + { + await cache.{|#0:SetAsync|}("a", "x"); + await cache.{|#1:SetAsync|}("b", "y"); + } + } + """; + + const string Fixed = """ + using System.Threading.Tasks; + using QuerySpec.Core.Caching; + + class C + { + async Task Multi(TestCacheProvider cache) + { + await cache.SetValueAsync("a", "x"); + await cache.SetValueAsync("b", "y"); + } + } + """; + + var diagnostics = new[] + { + VerifyCodeFix.Diagnostic(DiagnosticIds.CacheProviderInvocation).WithLocation(0).WithArguments("SetAsync", "SetValueAsync"), + VerifyCodeFix.Diagnostic(DiagnosticIds.CacheProviderInvocation).WithLocation(1).WithArguments("SetAsync", "SetValueAsync"), + }; + + await VerifyCodeFix.VerifyCodeFixAsync(Source, diagnostics, Fixed, StubSources); + } +} diff --git a/tests/QuerySpec.Analyzers.Tests/GeoLocationMemberAccessAnalyzerTests.cs b/tests/QuerySpec.Analyzers.Tests/GeoLocationMemberAccessAnalyzerTests.cs new file mode 100644 index 0000000..8684494 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/GeoLocationMemberAccessAnalyzerTests.cs @@ -0,0 +1,233 @@ +using System.Threading.Tasks; +using QuerySpec.Analyzers.Tests.Verifiers; +using Xunit; +using VerifyAnalyzer = QuerySpec.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; +using VerifyCodeFix = QuerySpec.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace QuerySpec.Analyzers.Tests; + +public sealed class GeoLocationMemberAccessAnalyzerTests +{ + private static readonly string[] StubSources = { TestStubs.GeoTypes }; + + [Fact] + public async Task Latitude_Read_On_GeoLocation_Triggers_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoLocation legacy) + { + return (double)legacy.{|#0:Latitude|}; + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.GeoLocationMemberAccess) + .WithLocation(0) + .WithArguments("Latitude"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task Longitude_Read_On_GeoLocation_Triggers_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoLocation legacy) + { + return (double)legacy.{|#0:Longitude|}; + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(DiagnosticIds.GeoLocationMemberAccess) + .WithLocation(0) + .WithArguments("Longitude"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources, expected); + } + + [Fact] + public async Task Latitude_Read_On_GeoCoordinate_Does_Not_Trigger() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoCoordinate coord) => coord.Latitude; + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task Member_On_Unrelated_Type_Does_Not_Trigger() + { + const string Source = """ + class Other + { + public double Latitude { get; set; } + public double Longitude { get; set; } + } + + class C + { + double M(Other o) => o.Latitude + o.Longitude; + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source); + } + + [Fact] + public async Task Suppression_Pragma_Silences_Diagnostic() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoLocation legacy) + { + #pragma warning disable QSPEC0001 + var lat = (double)legacy.Latitude; + #pragma warning restore QSPEC0001 + return lat; + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, StubSources); + } + + [Fact] + public async Task Analyzer_Suppresses_When_Property_Already_Has_Matching_Obsolete_DiagnosticId() + { + const string ObsoleteStub = """ + using System; + + namespace QuerySpec.Core.Advanced + { + public class GeoLocation + { + [Obsolete("Use GeoCoordinate", DiagnosticId = "QSPEC0001")] + public decimal Latitude { get; set; } + } + + public readonly record struct GeoCoordinate + { + public double Latitude { get; } + } + } + """; + const string Source = """ + class C + { + decimal M(QuerySpec.Core.Advanced.GeoLocation legacy) + { + #pragma warning disable QSPEC0001 + return legacy.Latitude; + #pragma warning restore QSPEC0001 + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(Source, new[] { ObsoleteStub }); + } + + [Fact] + public void HelpLinkUri_Points_At_GitHub_Diagnostic_Reference() + { + var descriptor = DiagnosticDescriptors.GeoLocationMemberAccess; + Assert.Equal( + "https://github.com/AbongileBoja/QuerySpec/blob/main/docs/diagnostics/QSPEC0001.md", + descriptor.HelpLinkUri); + Assert.Equal("QSPEC0001", descriptor.Id); + } + + [Fact] + public async Task CodeFix_Rewrites_Latitude_Read_As_ToGeoCoordinate_Call() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoLocation legacy) + { + return (double)legacy.{|#0:Latitude|}; + } + } + """; + + const string Fixed = """ + using QuerySpec.Core.Advanced; + + class C + { + double M(GeoLocation legacy) + { + return (double)legacy.ToGeoCoordinate().Latitude; + } + } + """; + + var expected = VerifyCodeFix.Diagnostic(DiagnosticIds.GeoLocationMemberAccess) + .WithLocation(0) + .WithArguments("Latitude"); + + await VerifyCodeFix.VerifyCodeFixAsync(Source, new[] { expected }, Fixed, StubSources); + } + + [Fact] + public async Task FixAll_Rewrites_Multiple_Latitude_Longitude_Reads_In_Document() + { + const string Source = """ + using QuerySpec.Core.Advanced; + + class C + { + double Sum(GeoLocation a, GeoLocation b) + { + var x = (double)a.{|#0:Latitude|}; + var y = (double)a.{|#1:Longitude|}; + var z = (double)b.{|#2:Latitude|}; + return x + y + z; + } + } + """; + + const string Fixed = """ + using QuerySpec.Core.Advanced; + + class C + { + double Sum(GeoLocation a, GeoLocation b) + { + var x = (double)a.ToGeoCoordinate().Latitude; + var y = (double)a.ToGeoCoordinate().Longitude; + var z = (double)b.ToGeoCoordinate().Latitude; + return x + y + z; + } + } + """; + + var diagnostics = new[] + { + VerifyCodeFix.Diagnostic(DiagnosticIds.GeoLocationMemberAccess).WithLocation(0).WithArguments("Latitude"), + VerifyCodeFix.Diagnostic(DiagnosticIds.GeoLocationMemberAccess).WithLocation(1).WithArguments("Longitude"), + VerifyCodeFix.Diagnostic(DiagnosticIds.GeoLocationMemberAccess).WithLocation(2).WithArguments("Latitude"), + }; + + await VerifyCodeFix.VerifyCodeFixAsync(Source, diagnostics, Fixed, StubSources); + } +} diff --git a/tests/QuerySpec.Analyzers.Tests/QuerySpec.Analyzers.Tests.csproj b/tests/QuerySpec.Analyzers.Tests/QuerySpec.Analyzers.Tests.csproj new file mode 100644 index 0000000..25b5c51 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/QuerySpec.Analyzers.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0;net9.0;net10.0 + enable + latest + true + Exe + $(NoWarn);CS1591;xUnit1051 + false + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs new file mode 100644 index 0000000..82ca6d8 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace QuerySpec.Analyzers.Tests.Verifiers; + +/// +/// Thin wrapper over the Roslyn analyzer-testing harness pre-wired to net8 reference assemblies. +/// Tests inline their own stub types for the deprecated members so the analyzer is exercised in +/// isolation from the production [Obsolete(DiagnosticId="QSPEC####")] attribute path. +/// +/// The analyzer under test. +public static class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + public static DiagnosticResult Diagnostic(string diagnosticId) + => new(diagnosticId, DiagnosticSeverity.Warning); + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => new(descriptor); + + public static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + => VerifyAnalyzerAsync(source, additionalSources: System.Array.Empty(), expected); + + public static Task VerifyAnalyzerAsync(string source, string[] additionalSources, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + }; + foreach (var extra in additionalSources) + { + test.TestState.Sources.Add(extra); + } + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + public sealed class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies = QuerySpecReferenceAssemblies.Default; + } + } +} diff --git a/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs new file mode 100644 index 0000000..d70eb82 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -0,0 +1,75 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace QuerySpec.Analyzers.Tests.Verifiers; + +/// +/// Thin wrapper over the Roslyn code-fix-testing harness pre-wired to net8 reference assemblies. +/// +/// The analyzer that produces the diagnostic to fix. +/// The code-fix provider under test. +public static class CSharpCodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + public static DiagnosticResult Diagnostic(string diagnosticId) + => new(diagnosticId, DiagnosticSeverity.Warning); + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => new(descriptor); + + public static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + => VerifyAnalyzerAsync(source, System.Array.Empty(), expected); + + public static Task VerifyAnalyzerAsync(string source, string[] additionalSources, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + }; + foreach (var extra in additionalSources) + { + test.TestState.Sources.Add(extra); + } + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + public static Task VerifyCodeFixAsync(string source, string fixedSource) + => VerifyCodeFixAsync(source, System.Array.Empty(), fixedSource, System.Array.Empty()); + + public static Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + => VerifyCodeFixAsync(source, new[] { expected }, fixedSource, System.Array.Empty()); + + public static Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource) + => VerifyCodeFixAsync(source, expected, fixedSource, System.Array.Empty()); + + public static Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource, string[] additionalSources) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + }; + foreach (var extra in additionalSources) + { + test.TestState.Sources.Add(extra); + test.FixedState.Sources.Add(extra); + } + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + public sealed class Test : CSharpCodeFixTest + { + public Test() + { + ReferenceAssemblies = QuerySpecReferenceAssemblies.Default; + } + } +} diff --git a/tests/QuerySpec.Analyzers.Tests/Verifiers/QuerySpecReferenceAssemblies.cs b/tests/QuerySpec.Analyzers.Tests/Verifiers/QuerySpecReferenceAssemblies.cs new file mode 100644 index 0000000..1f5ab70 --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/Verifiers/QuerySpecReferenceAssemblies.cs @@ -0,0 +1,13 @@ +using Microsoft.CodeAnalysis.Testing; + +namespace QuerySpec.Analyzers.Tests.Verifiers; + +/// +/// Reference-assembly bundle every analyzer test inherits. Pinned to .NET 8 so the verifier +/// resolves a stable BCL surface across CI runners; the runtime test project itself executes on +/// net8/9/10 unchanged. +/// +internal static class QuerySpecReferenceAssemblies +{ + public static ReferenceAssemblies Default { get; } = ReferenceAssemblies.Net.Net80; +} diff --git a/tests/QuerySpec.Analyzers.Tests/Verifiers/TestStubs.cs b/tests/QuerySpec.Analyzers.Tests/Verifiers/TestStubs.cs new file mode 100644 index 0000000..184644c --- /dev/null +++ b/tests/QuerySpec.Analyzers.Tests/Verifiers/TestStubs.cs @@ -0,0 +1,102 @@ +namespace QuerySpec.Analyzers.Tests.Verifiers; + +/// +/// Shared stub source fragments for analyzer tests. The stubs reproduce the public shape of the +/// QuerySpec.Core types under test (names, namespaces, signatures) without the production +/// [Obsolete(DiagnosticId = "QSPEC####")] attributes — tests therefore observe ONLY the +/// analyzer's emitted diagnostic, not the compiler's already-shipped obsolete warning. This is +/// the same isolation strategy Roslyn's own analyzer test suite uses for OptionalAttribute and +/// SuppressMessageAttribute scenarios. +/// +internal static class TestStubs +{ + /// Stub of QuerySpec.Core.Advanced.GeoLocation and GeoCoordinate plus the migration helper. + public const string GeoTypes = """ + namespace QuerySpec.Core.Advanced + { + public class GeoLocation + { + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public GeoLocation() { } + public GeoLocation(decimal latitude, decimal longitude) { Latitude = latitude; Longitude = longitude; } + public GeoCoordinate ToGeoCoordinate() => new((double)Latitude, (double)Longitude); + } + + public readonly record struct GeoCoordinate + { + public double Latitude { get; } + public double Longitude { get; } + public GeoCoordinate(double latitude, double longitude) { Latitude = latitude; Longitude = longitude; } + } + } + """; + + /// Stub of QuerySpec.Core.Advanced.AdvancedFilterExpression and FilterSpec. + public const string FilterTypes = """ + namespace QuerySpec.Core.Advanced + { + public enum FilterOperator { Equal, NotEqual } + + public class AdvancedFilterExpression + { + public string Field { get; set; } = ""; + public FilterOperator Operator { get; set; } + public object? Value { get; set; } + public AdvancedFilterExpression() { } + } + + public sealed record FilterSpec + { + public string Field { get; init; } = ""; + public FilterOperator Operator { get; init; } + public object? Value { get; init; } + } + } + """; + + /// Stub of QuerySpec.Core.Caching.ICacheProvider, ICacheStore, CacheResult, and a dual-interface concrete provider. + public const string CacheTypes = """ + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace QuerySpec.Core.Caching + { + public readonly struct CacheResult + { + public bool HasValue { get; } + public T Value { get; } + public CacheResult(bool hasValue, T value) { HasValue = hasValue; Value = value; } + public T? GetValueOrDefault() => HasValue ? Value : default; + public T GetValueOrDefault(T fallback) => HasValue ? Value : fallback; + } + + public interface ICacheProvider + { + ValueTask GetAsync(string key, CancellationToken cancellationToken = default) where T : class; + ValueTask SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class; + ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default); + } + + public interface ICacheStore + { + ValueTask> TryGetAsync(string key, CancellationToken cancellationToken = default); + ValueTask SetValueAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); + ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default); + } + + // Mirrors the shipping MemoryCacheProvider/DistributedCacheProvider/MultiLevelCache + // shape: dual-interface so a consumer who already has the concrete instance can call + // either contract. The QSPEC0003 codefix's well-typed scenario. + public class TestCacheProvider : ICacheProvider, ICacheStore + { + public ValueTask GetAsync(string key, CancellationToken cancellationToken = default) where T : class => default; + public ValueTask SetAsync(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class => default; + public ValueTask> TryGetAsync(string key, CancellationToken cancellationToken = default) => default; + public ValueTask SetValueAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) => default; + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) => default; + } + } + """; +}