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;
+ }
+ }
+ """;
+}