diff --git a/.gitignore b/.gitignore
index cbca5af..f7684e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
-# Scribe LocalDev sentinel
-.localscribe
+# Scribe LocalDev sentinels — per-producer (.scribe.user, .hermetic.local, ...)
+# Either suffix activates producer mode; both are supported (see Scribe docs).
+*.user
+*.local
# Build output
bin/
diff --git a/Directory.Build.props b/Directory.Build.props
index a3f7760..e48862f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,17 +12,20 @@
true
false
-
- $(NoWarn);CA1707
+
+ $(NoWarn);CA1707;RS2008
+ so we set them here and import the props file directly.
+ $(ScribesName) drives sentinel detection (.scribe.user or .scribe.local),
+ auto-pack, and override-file naming. -->
$(MSBuildThisFileDirectory)..
- Scribe
- BulletsForHumanity.Scribe
+ Scribe
@@ -63,9 +66,9 @@
============================================================ -->
- $(ArtifactsPath)packages/
+ When this producer is in local mode, Scribe.LocalDev.props redirects the
+ trigger project to the shared artifacts directory — don't overwrite that. -->
+ $(ArtifactsPath)packages/
Max Obrist
Bullets for Humanity
LICENSE
diff --git a/Directory.Build.targets b/Directory.Build.targets
index edc0cb5..1847a58 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -15,7 +15,7 @@
Name="_ScribeLocalDevTimestampVersion"
AfterTargets="GetBuildVersion"
BeforeTargets="Build;Pack;GenerateNuspec"
- Condition="'$(IsLocalScribe)' == 'true' and '$(IsPackable)' != 'false'"
+ Condition="'$(IsLocalProducer)' == 'true' and '$(IsPackable)' != 'false'"
>
<_DevTimestamp>$([System.DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss-fff"))
diff --git a/Scribe.Ink/Scribe.Ink.csproj b/Scribe.Ink/Scribe.Ink.csproj
new file mode 100644
index 0000000..986d483
--- /dev/null
+++ b/Scribe.Ink/Scribe.Ink.csproj
@@ -0,0 +1,46 @@
+
+
+
+ netstandard2.0
+ 14
+ true
+ false
+ Analyzer
+ false
+ embedded
+ false
+ true
+
+ $(NoWarn);NU5128;RS1038;RS2008
+ BulletsForHumanity.Scribe.Ink
+ Roslyn analyzers, code fix providers, and source generators for Scribe shapes. The scribe dips the quill in ink to mark, correct, and emend.
+ roslyn;analyzer;code-fix;source-generator;scribe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
new file mode 100644
index 0000000..33b6d0d
--- /dev/null
+++ b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
@@ -0,0 +1,169 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// SCRIBE101 — forbid Roslyn reference types on any TModel passed to
+/// Shape<TModel>.Project<TModel>. Holding an ISymbol,
+/// SyntaxNode, Compilation, SemanticModel, SyntaxTree,
+/// Location, or AttributeData in a cached model defeats the
+/// incremental generator cache: those objects identity-compare per compilation.
+/// Extract the primitive data you need into strings, equatable arrays, or
+/// instead.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class CacheCorrectnessAnalyzer : DiagnosticAnalyzer
+{
+ public const string DiagnosticId = "SCRIBE101";
+
+ private static readonly DiagnosticDescriptor Descriptor = new(
+ id: DiagnosticId,
+ title: "Cache-hostile type in Shape projection model",
+ messageFormat: "Member '{0}' of type '{1}' is a Roslyn reference type ('{2}') — storing it in a Shape projection model defeats incremental caching. Extract primitives or use LocationInfo instead.",
+ category: "Scribe.Cache",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ public override ImmutableArray SupportedDiagnostics { get; }
+ = ImmutableArray.Create(Descriptor);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context)
+ {
+ var shapeBuilder = context.Compilation.GetTypeByMetadataName("Scribe.Shapes.ShapeBuilder");
+ if (shapeBuilder is null)
+ {
+ return;
+ }
+
+ var forbidden = CollectForbiddenTypes(context.Compilation);
+ if (forbidden.IsEmpty)
+ {
+ return;
+ }
+
+ context.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, shapeBuilder, forbidden),
+ OperationKind.Invocation);
+ }
+
+ private static ImmutableArray CollectForbiddenTypes(Compilation compilation)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+ void Add(string metadataName)
+ {
+ var t = compilation.GetTypeByMetadataName(metadataName);
+ if (t is not null)
+ {
+ builder.Add(t);
+ }
+ }
+
+ Add("Microsoft.CodeAnalysis.ISymbol");
+ Add("Microsoft.CodeAnalysis.SyntaxNode");
+ Add("Microsoft.CodeAnalysis.SyntaxTree");
+ Add("Microsoft.CodeAnalysis.SemanticModel");
+ Add("Microsoft.CodeAnalysis.Compilation");
+ Add("Microsoft.CodeAnalysis.Location");
+ Add("Microsoft.CodeAnalysis.AttributeData");
+
+ return builder.ToImmutable();
+ }
+
+ private static void AnalyzeInvocation(
+ OperationAnalysisContext context,
+ INamedTypeSymbol shapeBuilder,
+ ImmutableArray forbidden)
+ {
+ if (context.Operation is not IInvocationOperation invocation)
+ {
+ return;
+ }
+
+ var method = invocation.TargetMethod;
+ if (method.Name != "Project"
+ || method.TypeArguments.Length != 1
+ || !SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, shapeBuilder))
+ {
+ return;
+ }
+
+ if (method.TypeArguments[0] is not INamedTypeSymbol model)
+ {
+ return;
+ }
+
+ foreach (var member in model.GetMembers())
+ {
+ if (member.IsStatic)
+ {
+ continue;
+ }
+
+ var memberType = member switch
+ {
+ IFieldSymbol f when !f.IsConst && !f.IsImplicitlyDeclared => f.Type,
+ IPropertySymbol p when !p.IsIndexer => p.Type,
+ _ => null,
+ };
+
+ if (memberType is null)
+ {
+ continue;
+ }
+
+ var offender = FindForbidden(memberType, forbidden);
+ if (offender is null)
+ {
+ continue;
+ }
+
+ var location = member.Locations.Length > 0 ? member.Locations[0] : invocation.Syntax.GetLocation();
+ context.ReportDiagnostic(Diagnostic.Create(
+ Descriptor,
+ location,
+ member.Name,
+ model.Name,
+ offender.ToDisplayString()));
+ }
+ }
+
+ private static ITypeSymbol? FindForbidden(ITypeSymbol type, ImmutableArray forbidden)
+ {
+ var current = type;
+ while (current is not null)
+ {
+ foreach (var banned in forbidden)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, banned))
+ {
+ return banned;
+ }
+ }
+
+ foreach (var iface in current.AllInterfaces)
+ {
+ foreach (var banned in forbidden)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, banned))
+ {
+ return banned;
+ }
+ }
+ }
+
+ current = current.BaseType;
+ }
+
+ return null;
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs
new file mode 100644
index 0000000..9b73985
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs
@@ -0,0 +1,47 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddAbstractModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Add 'abstract' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ if (typeDecl.Modifiers.Any(SyntaxKind.AbstractKeyword))
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var abstractToken = SyntaxFactory.Token(SyntaxKind.AbstractKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+
+ var modifiers = typeDecl.Modifiers;
+ var insertAt = modifiers.Count;
+ for (var i = 0; i < modifiers.Count; i++)
+ {
+ if (modifiers[i].IsKind(SyntaxKind.PartialKeyword))
+ {
+ insertAt = i;
+ break;
+ }
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, abstractToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs b/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs
new file mode 100644
index 0000000..d7ed531
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs
@@ -0,0 +1,57 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddAttributeFix : IShapeFix
+{
+ public string Title(Diagnostic diagnostic)
+ {
+ var name = AttributeName(diagnostic);
+ return name is null ? "Add required attribute" : $"Add '[{Shorten(name)}]'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ var attributeName = AttributeName(diagnostic);
+ if (attributeName is null)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var attr = SyntaxFactory.Attribute(SyntaxFactory.ParseName(Shorten(attributeName)));
+ var attrList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attr));
+
+ var newTypeDecl = typeDecl.WithAttributeLists(typeDecl.AttributeLists.Add(attrList));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ private static string? AttributeName(Diagnostic diagnostic)
+ {
+ return diagnostic.Properties.TryGetValue("attribute", out var name) && !string.IsNullOrEmpty(name)
+ ? name
+ : null;
+ }
+
+ // Strip trailing "Attribute" for the bracket form; keep full name otherwise.
+ private static string Shorten(string name)
+ {
+ const string Suffix = "Attribute";
+ return name.EndsWith(Suffix, System.StringComparison.Ordinal)
+ ? name.Substring(0, name.Length - Suffix.Length)
+ : name;
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs b/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs
new file mode 100644
index 0000000..0f76201
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs
@@ -0,0 +1,51 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddInterfaceToBaseListFix : IShapeFix
+{
+ public string Title(Diagnostic diagnostic)
+ {
+ var name = InterfaceName(diagnostic);
+ return name is null ? "Add required interface" : $"Implement '{name}'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ var interfaceName = InterfaceName(diagnostic);
+ if (interfaceName is null)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var baseType = SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(interfaceName));
+
+ var newTypeDecl = typeDecl.BaseList is null
+ ? typeDecl.WithBaseList(SyntaxFactory.BaseList(
+ SyntaxFactory.SingletonSeparatedList(baseType)))
+ : typeDecl.WithBaseList(typeDecl.BaseList.AddTypes(baseType));
+
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ private static string? InterfaceName(Diagnostic diagnostic)
+ {
+ return diagnostic.Properties.TryGetValue("interface", out var name) && !string.IsNullOrEmpty(name)
+ ? name
+ : null;
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs
new file mode 100644
index 0000000..5659f0f
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs
@@ -0,0 +1,35 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddPartialModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Add 'partial' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ if (typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var partialToken = SyntaxFactory.Token(SyntaxKind.PartialKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+ var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Add(partialToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs
new file mode 100644
index 0000000..9eec62d
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs
@@ -0,0 +1,49 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddSealedModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Add 'sealed' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ if (typeDecl.Modifiers.Any(SyntaxKind.SealedKeyword))
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var sealedToken = SyntaxFactory.Token(SyntaxKind.SealedKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+
+ // Insert 'sealed' before 'partial' / 'abstract' / type keyword for idiomatic ordering.
+ var modifiers = typeDecl.Modifiers;
+ var insertAt = modifiers.Count;
+ for (var i = 0; i < modifiers.Count; i++)
+ {
+ if (modifiers[i].IsKind(SyntaxKind.PartialKeyword)
+ || modifiers[i].IsKind(SyntaxKind.AbstractKeyword))
+ {
+ insertAt = i;
+ break;
+ }
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, sealedToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/IShapeFix.cs b/Scribe.Ink/Shapes/IShapeFix.cs
new file mode 100644
index 0000000..f8253a5
--- /dev/null
+++ b/Scribe.Ink/Shapes/IShapeFix.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// A single code-fix action that operates on a type declaration. Each
+/// maps to one implementation.
+///
+internal interface IShapeFix
+{
+ string Title(Diagnostic diagnostic);
+
+ Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct);
+}
diff --git a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs
new file mode 100644
index 0000000..ca00f35
--- /dev/null
+++ b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Scribe.Ink.Shapes.Fixes;
+using Scribe.Shapes;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// Dispatching for Shape-emitted diagnostics.
+/// Reads the fixKind property written by the analyzer and delegates
+/// to the matching implementation.
+///
+internal sealed class ShapeCodeFixProvider : CodeFixProvider
+{
+ private readonly ImmutableArray _ids;
+
+ internal ShapeCodeFixProvider(ImmutableArray diagnosticIds) => _ids = diagnosticIds;
+
+ public override ImmutableArray FixableDiagnosticIds => _ids;
+
+ 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)
+ {
+ if (!diagnostic.Properties.TryGetValue("fixKind", out var fixKindStr)
+ || string.IsNullOrEmpty(fixKindStr)
+ || !Enum.TryParse(fixKindStr, out var fixKind)
+ || fixKind == FixKind.None)
+ {
+ continue;
+ }
+
+ var fix = ResolveFix(fixKind);
+ if (fix is null)
+ {
+ continue;
+ }
+
+ var node = root.FindNode(diagnostic.Location.SourceSpan);
+ var typeDecl = node.AncestorsAndSelf().OfType().FirstOrDefault();
+ if (typeDecl is null)
+ {
+ continue;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: fix.Title(diagnostic),
+ createChangedDocument: ct => fix.FixAsync(context.Document, typeDecl, diagnostic, ct),
+ equivalenceKey: fixKind.ToString()),
+ diagnostic);
+ }
+ }
+
+ private static IShapeFix? ResolveFix(FixKind kind) => kind switch
+ {
+ FixKind.AddPartialModifier => new AddPartialModifierFix(),
+ FixKind.AddSealedModifier => new AddSealedModifierFix(),
+ FixKind.AddInterfaceToBaseList => new AddInterfaceToBaseListFix(),
+ FixKind.AddAttribute => new AddAttributeFix(),
+ _ => null,
+ };
+}
diff --git a/Scribe.Ink/Shapes/ShapeInkExtensions.cs b/Scribe.Ink/Shapes/ShapeInkExtensions.cs
new file mode 100644
index 0000000..17cb1aa
--- /dev/null
+++ b/Scribe.Ink/Shapes/ShapeInkExtensions.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Scribe.Shapes;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// Entry points that project a into the Roslyn
+/// analyzer/fixer surface. Lives in Scribe.Ink so Scribe core stays
+/// free of the Microsoft.CodeAnalysis.CSharp.Workspaces dependency.
+///
+public static class ShapeInkExtensions
+{
+ ///
+ /// Return a that offers code fixes for the
+ /// diagnostics emitted by this shape's analyzer. Package the returned
+ /// instance in a concrete [ExportCodeFixProvider]-attributed class
+ /// that delegates its members for deployment.
+ ///
+ public static CodeFixProvider ToFixProvider(this Shape shape)
+ where TModel : IEquatable
+ {
+ if (shape is null)
+ {
+ throw new ArgumentNullException(nameof(shape));
+ }
+
+ var builder = ImmutableArray.CreateBuilder(shape.CheckList.Length);
+ foreach (var check in shape.CheckList)
+ {
+ builder.Add(check.Id);
+ }
+
+ return new ShapeCodeFixProvider(builder.ToImmutable());
+ }
+}
diff --git a/Scribe.Scriptorium/Scribe.Scriptorium.csproj b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
new file mode 100644
index 0000000..a5a48bd
--- /dev/null
+++ b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
@@ -0,0 +1,29 @@
+
+
+
+ netstandard2.0
+ 14
+ true
+ false
+ false
+ false
+ embedded
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs b/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs
new file mode 100644
index 0000000..3562397
--- /dev/null
+++ b/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs
@@ -0,0 +1,332 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace Scribe.Scriptorium;
+
+///
+/// Meta-generator. Runs at Scribe's own compile time. Scans
+/// Scribe.Shapes.ShapeBuilder for every Must* primitive and
+/// emits severity variants into a partial class:
+///
+/// - Must* (positive) → Should* (Warning) + Could* (Info)
+/// - MustNot* → ShouldNot* (Warning) only
+///
+/// Each variant delegates to the handwritten Must* method, overriding
+/// only the severity when the caller has not supplied one.
+///
+[Generator(LanguageNames.CSharp)]
+public sealed class ShapeBuilderVariantsGenerator : IIncrementalGenerator
+{
+ private const string ShapeBuilderMetadataName = "Scribe.Shapes.ShapeBuilder";
+ private const string DiagnosticSpecFqn = "Scribe.Shapes.DiagnosticSpec";
+
+ // FullyQualifiedFormat minus UseSpecialTypes, so `System.String` doesn't collapse to the
+ // `string` keyword — we need the real type name to be able to prepend `global::`.
+ private static readonly SymbolDisplayFormat FullyQualifiedNoKeywords = new(
+ globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
+ typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
+ genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
+ miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
+ | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var source = context.CompilationProvider
+ .Select((comp, ct) => BuildSource(comp, ct));
+
+ context.RegisterSourceOutput(source, (spc, src) =>
+ {
+ if (!string.IsNullOrEmpty(src))
+ {
+ spc.AddSource("ShapeBuilder.Variants.g.cs", src!);
+ }
+ });
+ }
+
+ private static string? BuildSource(Compilation compilation, CancellationToken ct)
+ {
+ var shapeBuilder = compilation.GetTypeByMetadataName(ShapeBuilderMetadataName);
+ if (shapeBuilder is null)
+ {
+ return null;
+ }
+
+ var candidates = shapeBuilder.GetMembers()
+ .OfType()
+ .Where(IsVariantTarget)
+ .OrderBy(m => m.Name, StringComparer.Ordinal)
+ .ToList();
+
+ if (candidates.Count == 0)
+ {
+ return null;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using Microsoft.CodeAnalysis;");
+ sb.AppendLine();
+ sb.AppendLine("namespace Scribe.Shapes;");
+ sb.AppendLine();
+ sb.AppendLine("partial class ShapeBuilder");
+ sb.AppendLine("{");
+
+ var first = true;
+ foreach (var method in candidates)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (!first)
+ {
+ sb.AppendLine();
+ }
+
+ first = false;
+ EmitVariants(sb, method);
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static bool IsVariantTarget(IMethodSymbol method)
+ {
+ if (method.DeclaredAccessibility != Accessibility.Public
+ || method.IsStatic
+ || method.MethodKind != MethodKind.Ordinary)
+ {
+ return false;
+ }
+
+ if (!method.Name.StartsWith("Must", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (method.ReturnType.ToDisplayString() != ShapeBuilderMetadataName)
+ {
+ return false;
+ }
+
+ // Final parameter must be a DiagnosticSpec? — that's the override hook.
+ var lastParam = method.Parameters.LastOrDefault();
+ if (lastParam is null)
+ {
+ return false;
+ }
+
+ var type = lastParam.Type;
+ if (type is not INamedTypeSymbol named
+ || named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T
+ || named.TypeArguments.Length != 1
+ || named.TypeArguments[0].ToDisplayString() != DiagnosticSpecFqn)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void EmitVariants(StringBuilder sb, IMethodSymbol method)
+ {
+ var isNegative = method.Name.StartsWith("MustNot", StringComparison.Ordinal);
+ var bareName = isNegative ? method.Name.Substring("MustNot".Length) : method.Name.Substring("Must".Length);
+
+ if (isNegative)
+ {
+ EmitOne(sb, method, variantPrefix: "ShouldNot", bareName: bareName, severity: "Warning",
+ summary: $"Warning-level variant of .");
+ }
+ else
+ {
+ EmitOne(sb, method, variantPrefix: "Should", bareName: bareName, severity: "Warning",
+ summary: $"Warning-level variant of .");
+ sb.AppendLine();
+ EmitOne(sb, method, variantPrefix: "Could", bareName: bareName, severity: "Info",
+ summary: $"Info-level variant of .");
+ }
+ }
+
+ private static void EmitOne(
+ StringBuilder sb,
+ IMethodSymbol method,
+ string variantPrefix,
+ string bareName,
+ string severity,
+ string summary)
+ {
+ var newName = variantPrefix + bareName;
+
+ sb.Append(" /// ").Append(summary).AppendLine("");
+
+ sb.Append(" public global::Scribe.Shapes.ShapeBuilder ").Append(newName);
+ AppendTypeParameters(sb, method);
+ sb.Append('(');
+ AppendParameterList(sb, method);
+ sb.AppendLine(")");
+
+ AppendTypeParameterConstraints(sb, method);
+
+ sb.Append(" => ").Append(method.Name);
+ AppendTypeParameters(sb, method);
+ sb.Append('(');
+ AppendForwardingArgs(sb, method, severity);
+ sb.AppendLine(");");
+ }
+
+ private static void AppendTypeParameters(StringBuilder sb, IMethodSymbol method)
+ {
+ if (method.TypeParameters.Length == 0)
+ {
+ return;
+ }
+
+ sb.Append('<');
+ for (var i = 0; i < method.TypeParameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(", ");
+ }
+
+ sb.Append(method.TypeParameters[i].Name);
+ }
+
+ sb.Append('>');
+ }
+
+ private static void AppendTypeParameterConstraints(StringBuilder sb, IMethodSymbol method)
+ {
+ foreach (var tp in method.TypeParameters)
+ {
+ var parts = new List();
+ if (tp.HasReferenceTypeConstraint)
+ {
+ parts.Add("class");
+ }
+
+ if (tp.HasValueTypeConstraint)
+ {
+ parts.Add("struct");
+ }
+
+ if (tp.HasNotNullConstraint)
+ {
+ parts.Add("notnull");
+ }
+
+ if (tp.HasUnmanagedTypeConstraint)
+ {
+ parts.Add("unmanaged");
+ }
+
+ foreach (var ct in tp.ConstraintTypes)
+ {
+ parts.Add(ct.ToDisplayString(FullyQualifiedNoKeywords));
+ }
+
+ if (tp.HasConstructorConstraint)
+ {
+ parts.Add("new()");
+ }
+
+ if (parts.Count == 0)
+ {
+ continue;
+ }
+
+ sb.Append(" where ").Append(tp.Name).Append(" : ").AppendLine(string.Join(", ", parts));
+ }
+ }
+
+ private static void AppendParameterList(StringBuilder sb, IMethodSymbol method)
+ {
+ for (var i = 0; i < method.Parameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(", ");
+ }
+
+ var p = method.Parameters[i];
+ sb.Append(p.Type.ToDisplayString(FullyQualifiedNoKeywords));
+ sb.Append(' ').Append(p.Name);
+
+ if (p.HasExplicitDefaultValue)
+ {
+ sb.Append(" = ").Append(FormatDefault(p.ExplicitDefaultValue, p.Type));
+ }
+ }
+ }
+
+ private static void AppendForwardingArgs(StringBuilder sb, IMethodSymbol method, string severity)
+ {
+ var last = method.Parameters.Length - 1;
+ for (var i = 0; i < method.Parameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(", ");
+ }
+
+ var p = method.Parameters[i];
+ if (i == last)
+ {
+ sb.Append("global::Scribe.Shapes.SeverityVariants.WithSeverity(")
+ .Append(p.Name)
+ .Append(", global::Microsoft.CodeAnalysis.DiagnosticSeverity.")
+ .Append(severity)
+ .Append(')');
+ }
+ else
+ {
+ sb.Append(p.Name);
+ }
+ }
+ }
+
+ private static string FormatDefault(object? value, ITypeSymbol type)
+ {
+ if (value is null)
+ {
+ return type.IsValueType && type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T
+ ? "default"
+ : "null";
+ }
+
+ return value switch
+ {
+ bool b => b ? "true" : "false",
+ string s => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
+ _ => value.ToString() ?? "default",
+ };
+ }
+
+ private static string FormatXmlRef(IMethodSymbol method)
+ {
+ var builder = new StringBuilder();
+ builder.Append("Scribe.Shapes.ShapeBuilder.").Append(method.Name);
+ if (method.TypeParameters.Length > 0)
+ {
+ builder.Append('{');
+ for (var i = 0; i < method.TypeParameters.Length; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(',');
+ }
+
+ builder.Append(method.TypeParameters[i].Name);
+ }
+
+ builder.Append('}');
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/Scribe.Sdk/Scribe.Sdk.csproj b/Scribe.Sdk/Scribe.Sdk.csproj
index 741a423..7f26993 100644
--- a/Scribe.Sdk/Scribe.Sdk.csproj
+++ b/Scribe.Sdk/Scribe.Sdk.csproj
@@ -3,8 +3,8 @@
BulletsForHumanity.Scribe.Sdk — MSBuild SDK for Roslyn analyzer/generator projects.
This is a pack-only project. It produces no build output — the NuGet package
- contains only MSBuild props/targets, polyfill stubs, Solution-Local Analyzer
- infrastructure, and LocalDev infrastructure.
+ contains only MSBuild props/targets (phased under Sdk/Phases/), polyfill stubs,
+ and LocalDev infrastructure.
-->
BulletsForHumanity.Scribe.Sdk
@@ -19,7 +19,7 @@
$(NoWarn);NU5128;NU5129;NETSDK1212
true
- MSBuild SDK for Roslyn analyzer and source generator projects. Provides default project configuration, analyzer packaging targets, netstandard2.0 polyfill stubs, solution-local analyzer auto-pack support, and LocalDev multi-repo development infrastructure. Use with: <Project Sdk="BulletsForHumanity.Scribe.Sdk">
+ MSBuild SDK for Roslyn analyzer and source generator projects. Classifies projects as Library / Companion / Analyzer / Meta via <ScribeSdkProjectType>, automates Library↔Companion packaging (embedded analyzers in a single nupkg), provides Meta auto-pack for in-solution generators, ships netstandard2.0 polyfill stubs, and includes LocalDev multi-repo development infrastructure. Use with: <Project Sdk="BulletsForHumanity.Scribe.Sdk">
roslyn;analyzer;generator;source-generator;msbuild-sdk;scribe
@@ -39,12 +39,6 @@
-
-
-
-
-
-
diff --git a/Scribe.Sdk/Sdk/Phases/Analyzer.targets b/Scribe.Sdk/Sdk/Phases/Analyzer.targets
new file mode 100644
index 0000000..5608ed1
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Analyzer.targets
@@ -0,0 +1,45 @@
+
+
+
+
+
+ false
+ Analyzer
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ <_ScribeSdkDep Include="@(ReferenceCopyLocalPaths)"
+ Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != ''
+ and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True'
+ and '%(Extension)' == '.dll'" />
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Classification.targets b/Scribe.Sdk/Sdk/Phases/Classification.targets
new file mode 100644
index 0000000..173597f
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Classification.targets
@@ -0,0 +1,24 @@
+
+
+
+
+ <_IsScribeLibrary Condition="'$(ScribeSdkProjectType)' == 'Library'">true
+ <_IsScribeCompanion Condition="'$(ScribeSdkProjectType)' == 'Companion'">true
+ <_IsScribeAnalyzer Condition="'$(ScribeSdkProjectType)' == 'Analyzer'">true
+ <_IsScribeMeta Condition="'$(ScribeSdkProjectType)' == 'Meta'">true
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Companion.targets b/Scribe.Sdk/Sdk/Phases/Companion.targets
new file mode 100644
index 0000000..99c6d27
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Companion.targets
@@ -0,0 +1,77 @@
+
+
+
+ -->
+
+
+
+
+ false
+
+ false
+
+ true
+
+ $(GetTargetPathDependsOn);GetDependencyTargetPaths
+
+
+
+
+
+
+ <_AnalyzerFile Include="$(OutputPath)$(AssemblyName).dll" />
+ <_AnalyzerFile Include="@(ReferenceCopyLocalPaths)"
+ Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != ''
+ and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Common'
+ and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp'
+ and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp.Workspaces'
+ and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Workspaces.Common'
+ and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'System.Collections.Immutable'
+ and '%(Extension)' == '.dll'" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Defaults.props b/Scribe.Sdk/Sdk/Phases/Defaults.props
new file mode 100644
index 0000000..260b8b9
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Defaults.props
@@ -0,0 +1,43 @@
+
+
+
+
+
+ netstandard2.0
+
+
+ 14
+
+
+ enable
+
+
+ true
+
+
+ false
+ embedded
+
+
+ false
+
+
+ $(NoWarn);NU5128
+
+
+ true
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Library.targets b/Scribe.Sdk/Sdk/Phases/Library.targets
new file mode 100644
index 0000000..217f83c
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Library.targets
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+ true
+
+ $(NoWarn.Replace(';NU5128',''))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_ScribeCompanionAnalyzerFile
+ Include="@(_ScribeCompanionAnalyzerFileRaw)"
+ Condition="'@(_ScribeCompanionAnalyzerName)' != ''
+ and $([System.String]::new('%3B@(_ScribeCompanionAnalyzerName)%3B').Contains('%3B%(_ScribeCompanionAnalyzerFileRaw.Filename)%(_ScribeCompanionAnalyzerFileRaw.Extension)%3B'))" />
+
+
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Meta.targets b/Scribe.Sdk/Sdk/Phases/Meta.targets
new file mode 100644
index 0000000..9a6966f
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Meta.targets
@@ -0,0 +1,116 @@
+
+
+
+
+
+ false
+ Analyzer
+ true
+ $(GetTargetPathDependsOn);GetDependencyTargetPaths
+
+
+ <_ScribeMetaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
+ 0.0.0-dev.$(_ScribeMetaTimestamp)
+ 0.0.0-dev.$(_ScribeMetaTimestamp)
+
+
+ true
+ true
+ false
+
+
+
+
+ <_ScribeMetaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)'))
+ $(_ScribeMetaArtifactsDir)packages\
+ $(RestoreAdditionalProjectSources);$(_ScribeMetaArtifactsDir)packages
+
+
+
+
+
+
+
+
+
+
+
+ <_ScribeSdkMetaDep Include="@(ReferenceCopyLocalPaths)"
+ Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != ''
+ and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True'
+ and '%(Extension)' == '.dll'" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_ScribeMetaOverrideVersion>$(NuGetPackageVersion)
+ <_ScribeMetaOverrideVersion Condition="'$(_ScribeMetaOverrideVersion)' == ''">$(PackageVersion)
+ <_ScribeMetaOverridePath>$(_ScribeMetaArtifactsDir)$(PackageId).Directory.Packages.targets
+
+
+ <_ScribeMetaOverrideLine Include="<Project>" />
+ <_ScribeMetaOverrideLine Include=" <!-- Auto-generated by Scribe.Sdk (Meta). Rebuild to refresh. -->" />
+ <_ScribeMetaOverrideLine Include=" <ItemGroup>" />
+ <_ScribeMetaOverrideLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeMetaOverrideVersion)" />" />
+ <_ScribeMetaOverrideLine Include=" </ItemGroup>" />
+ <_ScribeMetaOverrideLine Include="</Project>" />
+
+
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Stubs.targets b/Scribe.Sdk/Sdk/Phases/Stubs.targets
new file mode 100644
index 0000000..fbfe015
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Stubs.targets
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Phases/Validation.targets b/Scribe.Sdk/Sdk/Phases/Validation.targets
new file mode 100644
index 0000000..5fe5adc
--- /dev/null
+++ b/Scribe.Sdk/Sdk/Phases/Validation.targets
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
diff --git a/Scribe.Sdk/Sdk/Sdk.props b/Scribe.Sdk/Sdk/Sdk.props
index 8471c46..bd3f7b6 100644
--- a/Scribe.Sdk/Sdk/Sdk.props
+++ b/Scribe.Sdk/Sdk/Sdk.props
@@ -1,64 +1,46 @@
-
+
-
-
-
-
- netstandard2.0
-
- 14
-
- true
-
- false
-
- Analyzer
-
- false
- embedded
-
- enable
-
- false
-
- $(NoWarn);NU5128
-
- true
-
+
+
-
-
-
- true
-
-
-
-
-
-
+
diff --git a/Scribe.Sdk/Sdk/Sdk.targets b/Scribe.Sdk/Sdk/Sdk.targets
index 3832a11..d1b9731 100644
--- a/Scribe.Sdk/Sdk/Sdk.targets
+++ b/Scribe.Sdk/Sdk/Sdk.targets
@@ -1,61 +1,24 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <_ScribeSdkDeps Include="@(ReferenceCopyLocalPaths)"
- Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != ''
- and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True'
- and '%(Extension)' == '.dll'" />
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/Scribe.Tests/Attributes/AttributeSchemaTests.cs b/Scribe.Tests/Attributes/AttributeSchemaTests.cs
new file mode 100644
index 0000000..1575c4a
--- /dev/null
+++ b/Scribe.Tests/Attributes/AttributeSchemaTests.cs
@@ -0,0 +1,365 @@
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Attributes;
+using Scribe.Cache;
+
+namespace Scribe.Tests.Attributes;
+
+public class AttributeSchemaTests
+{
+ private static INamedTypeSymbol GetType(string source, string metadataName = "Target")
+ {
+ var tree = CSharpSyntaxTree.ParseText(
+ source,
+ cancellationToken: TestContext.Current.CancellationToken);
+ var compilation = CSharpCompilation.Create(
+ "TestAsm",
+ syntaxTrees: [tree],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location),
+ ]);
+
+ var symbol = compilation.GetTypeByMetadataName(metadataName);
+ symbol.ShouldNotBeNull($"type '{metadataName}' not resolved; diagnostics: "
+ + string.Join("; ", compilation.GetDiagnostics(TestContext.Current.CancellationToken)
+ .Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString())));
+ return symbol!;
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Has / For basics
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Has_ReturnsTrue_WhenAttributePresent()
+ {
+ var symbol = GetType("""
+ using System;
+ [AttributeUsage(AttributeTargets.Class)]
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """);
+
+ AttributeSchema.Has(symbol, "FooAttribute").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Has_ReturnsFalse_WhenAttributeAbsent()
+ {
+ var symbol = GetType("public class Target {}");
+ AttributeSchema.Has(symbol, "FooAttribute").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Has_MatchesWithOrWithoutAttributeSuffix()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """);
+
+ AttributeSchema.Has(symbol, "FooAttribute").ShouldBeTrue();
+ AttributeSchema.Has(symbol, "Foo").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void For_MissingAttribute_ReaderExistsIsFalse()
+ {
+ var symbol = GetType("public class Target {}");
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Exists.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void For_MissingAttribute_ReadsReturnDefault_NoErrors()
+ {
+ var symbol = GetType("public class Target {}");
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+
+ reader.Ctor(0).ShouldBeNull();
+ reader.Named("X", 42).ShouldBe(42);
+ reader.DrainErrors().IsEmpty.ShouldBeTrue();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Ctor / CtorOpt
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Ctor_StringArg_HappyPath()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string name) {} }
+ [Foo("hello")] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Exists.ShouldBeTrue();
+ reader.Ctor(0).ShouldBe("hello");
+ reader.DrainErrors().IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Ctor_IntArg_HappyPath()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(int value) {} }
+ [Foo(42)] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Ctor(0).ShouldBe(42);
+ }
+
+ [Fact]
+ public void Ctor_MultipleArgs_ReadByIndex()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a, int b) {} }
+ [Foo("x", 7)] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Ctor(0).ShouldBe("x");
+ reader.Ctor(1).ShouldBe(7);
+ }
+
+ [Fact]
+ public void Ctor_OutOfRange_ReturnsDefault_PushesError()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} }
+ [Foo("x")] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Ctor(99).ShouldBeNull();
+
+ var errors = reader.DrainErrors();
+ errors.IsEmpty.ShouldBeFalse();
+ errors[0].Id.ShouldBe(DiagnosticIds.AttributeCtorMissing);
+ }
+
+ [Fact]
+ public void Ctor_TypeMismatch_ReturnsDefault_PushesError()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} }
+ [Foo("x")] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Ctor(0).ShouldBe(0);
+
+ var errors = reader.DrainErrors();
+ errors.IsEmpty.ShouldBeFalse();
+ errors[0].Id.ShouldBe(DiagnosticIds.AttributeValueMismatch);
+ }
+
+ [Fact]
+ public void CtorOpt_OutOfRange_SilentDefault()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} }
+ [Foo("x")] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.CtorOpt(99).ShouldBeNull();
+ reader.DrainErrors().IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CtorArgCount_ReportsActualCount()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(int a, int b, int c) {} }
+ [Foo(1, 2, 3)] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.CtorArgCount.ShouldBe(3);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Named
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Named_HappyPath()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public int Max { get; set; } }
+ [Foo(Max = 100)] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Named("Max", 0).ShouldBe(100);
+ }
+
+ [Fact]
+ public void Named_Missing_ReturnsFallback()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public int Max { get; set; } }
+ [Foo] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Named("Max", 42).ShouldBe(42);
+ reader.DrainErrors().IsEmpty.ShouldBeTrue();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // TypeArg
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TypeArg_ReadsGenericArgument()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ var typeArg = reader.TypeArg(0);
+ typeArg.ShouldNotBeNull();
+ typeArg!.Name.ShouldBe("Int32");
+ }
+
+ [Fact]
+ public void TypeArg_OutOfRange_PushesError()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.TypeArg(0).ShouldBeNull();
+ reader.DrainErrors()[0].Id.ShouldBe(DiagnosticIds.AttributeTypeArgMissing);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Location
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Location_CapturedForPresentAttribute()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """);
+
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Location.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Location_NullForMissingAttribute()
+ {
+ var symbol = GetType("public class Target {}");
+ var reader = AttributeSchema.For(symbol, "FooAttribute");
+ reader.Location.ShouldBeNull();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Read projection
+ // ───────────────────────────────────────────────────────────────
+
+ private readonly record struct FooModel(string Name, int Max);
+
+ [Fact]
+ public void Read_Projection_CapturesModelAndExists()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute
+ {
+ public FooAttribute(string name) {}
+ public int Max { get; set; }
+ }
+ [Foo("alpha", Max = 7)] public class Target {}
+ """);
+
+ var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) =>
+ new FooModel(
+ Name: r.Ctor(0) ?? string.Empty,
+ Max: r.Named("Max", 0)));
+
+ result.Exists.ShouldBeTrue();
+ result.Model.Name.ShouldBe("alpha");
+ result.Model.Max.ShouldBe(7);
+ result.Errors.IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Read_MissingAttribute_ProducesDefaultModel_ExistsFalse()
+ {
+ var symbol = GetType("public class Target {}");
+
+ var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) =>
+ new FooModel(
+ Name: r.Ctor(0) ?? "fallback",
+ Max: r.Named("Max", 99)));
+
+ result.Exists.ShouldBeFalse();
+ result.Model.Name.ShouldBe("fallback");
+ result.Model.Max.ShouldBe(99);
+ result.Errors.IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Read_CollectsErrors_FromTypedReadMismatches()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} }
+ [Foo("x")] public class Target {}
+ """);
+
+ var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) =>
+ new FooModel(
+ Name: r.Ctor(0) ?? "",
+ Max: r.Ctor(5))); // wrong index — pushes error
+
+ result.Exists.ShouldBeTrue();
+ result.Errors.IsEmpty.ShouldBeFalse();
+ result.Errors[0].Id.ShouldBe(DiagnosticIds.AttributeCtorMissing);
+ }
+
+ [Fact]
+ public void Read_IsEquatable_ByValue()
+ {
+ var source = """
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} }
+ [Foo("x")] public class Target {}
+ """;
+ var a = AttributeSchema.Read(GetType(source), "FooAttribute", (ref AttributeReader r) =>
+ new FooModel(r.Ctor(0) ?? "", 0));
+ var b = AttributeSchema.Read(GetType(source), "FooAttribute", (ref AttributeReader r) =>
+ new FooModel(r.Ctor(0) ?? "", 0));
+
+ a.ShouldBe(b);
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+}
diff --git a/Scribe.Tests/Cache/DiagnosticInfoTests.cs b/Scribe.Tests/Cache/DiagnosticInfoTests.cs
new file mode 100644
index 0000000..938e0fc
--- /dev/null
+++ b/Scribe.Tests/Cache/DiagnosticInfoTests.cs
@@ -0,0 +1,102 @@
+using System.Globalization;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Cache;
+
+namespace Scribe.Tests.Cache;
+
+public class DiagnosticInfoTests
+{
+ private static readonly DiagnosticDescriptor TestDescriptor = new(
+ id: "SCRIBE001",
+ title: "Test rule",
+ messageFormat: "Type '{0}' must be partial",
+ category: "Scribe.Test",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ [Fact]
+ public void Materialize_ProducesDiagnostic_WithSameId()
+ {
+ var info = new DiagnosticInfo(
+ Id: "SCRIBE001",
+ Severity: DiagnosticSeverity.Error,
+ MessageArgs: EquatableArray.Create("Foo"),
+ Location: null);
+
+ var diag = info.Materialize(TestDescriptor);
+
+ diag.Id.ShouldBe("SCRIBE001");
+ diag.Severity.ShouldBe(DiagnosticSeverity.Error);
+ }
+
+ [Fact]
+ public void Materialize_FormatsMessageArgs()
+ {
+ var info = new DiagnosticInfo(
+ Id: "SCRIBE001",
+ Severity: DiagnosticSeverity.Error,
+ MessageArgs: EquatableArray.Create("Bar"),
+ Location: null);
+
+ var diag = info.Materialize(TestDescriptor);
+
+ diag.GetMessage(CultureInfo.InvariantCulture).ShouldBe("Type 'Bar' must be partial");
+ }
+
+ [Fact]
+ public void Materialize_WithNullLocation_UsesLocationNone()
+ {
+ var info = new DiagnosticInfo(
+ Id: "SCRIBE001",
+ Severity: DiagnosticSeverity.Error,
+ MessageArgs: EquatableArray.Empty,
+ Location: null);
+
+ var diag = info.Materialize(TestDescriptor);
+
+ diag.Location.ShouldBe(Location.None);
+ }
+
+ [Fact]
+ public void Materialize_WithLocation_UsesProvidedLocation()
+ {
+ var location = new LocationInfo(
+ FilePath: "test.cs",
+ TextSpan: new TextSpan(0, 5),
+ LineSpan: new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 5)));
+ var info = new DiagnosticInfo(
+ Id: "SCRIBE001",
+ Severity: DiagnosticSeverity.Error,
+ MessageArgs: EquatableArray.Empty,
+ Location: location);
+
+ var diag = info.Materialize(TestDescriptor);
+
+ diag.Location.GetLineSpan().Path.ShouldBe("test.cs");
+ diag.Location.SourceSpan.ShouldBe(new TextSpan(0, 5));
+ }
+
+ [Fact]
+ public void Value_Equality_Works()
+ {
+ var a = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning,
+ EquatableArray.Create("X"), null);
+ var b = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning,
+ EquatableArray.Create("X"), null);
+
+ a.ShouldBe(b);
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Different_Ids_Not_Equal()
+ {
+ var a = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning,
+ EquatableArray.Empty, null);
+ var b = new DiagnosticInfo("SCRIBE002", DiagnosticSeverity.Warning,
+ EquatableArray.Empty, null);
+
+ a.ShouldNotBe(b);
+ }
+}
diff --git a/Scribe.Tests/Cache/EquatableArrayTests.cs b/Scribe.Tests/Cache/EquatableArrayTests.cs
new file mode 100644
index 0000000..3ebd9f8
--- /dev/null
+++ b/Scribe.Tests/Cache/EquatableArrayTests.cs
@@ -0,0 +1,211 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Scribe.Cache;
+
+namespace Scribe.Tests.Cache;
+
+public class EquatableArrayTests
+{
+ // ───────────────────────────────────────────────────────────────
+ // default / empty normalisation
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Default_IsEmpty()
+ {
+ var @default = default(EquatableArray);
+ @default.IsEmpty.ShouldBeTrue();
+ @default.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Default_Equals_Empty()
+ {
+ var @default = default(EquatableArray);
+ @default.ShouldBe(EquatableArray.Empty);
+ @default.GetHashCode().ShouldBe(EquatableArray.Empty.GetHashCode());
+ }
+
+ [Fact]
+ public void Empty_FromImmutableArray_Equals_Default()
+ {
+ var wrapped = new EquatableArray(ImmutableArray.Empty);
+ wrapped.ShouldBe(default(EquatableArray));
+ }
+
+ [Fact]
+ public void Default_AsSpan_DoesNotThrow()
+ {
+ var @default = default(EquatableArray);
+ var span = @default.AsSpan();
+ span.Length.ShouldBe(0);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // value equality
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Equal_Contents_Are_Equal()
+ {
+ var a = EquatableArray.Create(1, 2, 3);
+ var b = EquatableArray.Create(1, 2, 3);
+ a.Equals(b).ShouldBeTrue();
+ (a == b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Different_Contents_Are_Not_Equal()
+ {
+ var a = EquatableArray.Create(1, 2, 3);
+ var b = EquatableArray.Create(1, 2, 4);
+ a.Equals(b).ShouldBeFalse();
+ (a != b).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Different_Length_Not_Equal()
+ {
+ var a = EquatableArray.Create(1, 2, 3);
+ var b = EquatableArray.Create(1, 2);
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void String_Contents_Equality_Works()
+ {
+ var a = EquatableArray.Create("foo", "bar");
+ var b = EquatableArray.Create("foo", "bar");
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void ImmutableArray_Built_Different_Ways_Are_Equal()
+ {
+ // Different underlying T[] references, same contents — must be equal.
+ int[] source = [10, 20, 30];
+ var a = new EquatableArray(ImmutableArray.Create(source));
+ var b = new EquatableArray(ImmutableArray.Create(10, 20, 30));
+ a.Equals(b).ShouldBeTrue();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // indexer, span, enumeration
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Indexer_ReturnsElement()
+ {
+ var a = EquatableArray.Create(10, 20, 30);
+ a[0].ShouldBe(10);
+ a[1].ShouldBe(20);
+ a[2].ShouldBe(30);
+ }
+
+ [Fact]
+ public void Count_ReturnsLength()
+ {
+ EquatableArray.Create(1, 2, 3, 4, 5).Count.ShouldBe(5);
+ }
+
+ [Fact]
+ public void AsSpan_ReturnsElements()
+ {
+ var a = EquatableArray.Create(10, 20, 30);
+ var span = a.AsSpan();
+ span.Length.ShouldBe(3);
+ span[0].ShouldBe(10);
+ span[2].ShouldBe(30);
+ }
+
+ [Fact]
+ public void Enumeration_YieldsAllElements()
+ {
+ var a = EquatableArray.Create(1, 2, 3);
+ var collected = new List();
+ foreach (var item in a)
+ {
+ collected.Add(item);
+ }
+
+ collected.Count.ShouldBe(3);
+ collected[0].ShouldBe(1);
+ collected[1].ShouldBe(2);
+ collected[2].ShouldBe(3);
+ }
+
+ [Fact]
+ public void Enumeration_OverDefault_YieldsNothing()
+ {
+ var a = default(EquatableArray);
+ var collected = new List();
+ foreach (var item in a)
+ {
+ collected.Add(item);
+ }
+
+ collected.Count.ShouldBe(0);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // factory methods
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_FromParams_Works()
+ {
+ var a = EquatableArray.Create(1, 2, 3);
+ a.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void Create_FromEmptyParams_ReturnsEmpty()
+ {
+ var a = EquatableArray.Create();
+ a.IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void From_Enumerable_Works()
+ {
+ var source = new[] { 1, 2, 3 };
+ var a = EquatableArray.From(source);
+ a.Count.ShouldBe(3);
+ a[1].ShouldBe(2);
+ }
+
+ [Fact]
+ public void From_NullEnumerable_ReturnsEmpty()
+ {
+ var a = EquatableArray.From(null!);
+ a.IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ImplicitConversion_FromImmutableArray()
+ {
+ EquatableArray a = ImmutableArray.Create(1, 2, 3);
+ a.Count.ShouldBe(3);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // nested equatable array (composition)
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Nested_EquatableArray_Equals_By_Value()
+ {
+ var inner1 = EquatableArray.Create(1, 2);
+ var inner2 = EquatableArray.Create(3, 4);
+ var outerA = EquatableArray.Create(inner1, inner2);
+
+ var inner1Copy = EquatableArray.Create(1, 2);
+ var inner2Copy = EquatableArray.Create(3, 4);
+ var outerB = EquatableArray.Create(inner1Copy, inner2Copy);
+
+ outerA.Equals(outerB).ShouldBeTrue();
+ outerA.GetHashCode().ShouldBe(outerB.GetHashCode());
+ }
+}
diff --git a/Scribe.Tests/Cache/InternPoolTests.cs b/Scribe.Tests/Cache/InternPoolTests.cs
new file mode 100644
index 0000000..7ac9abb
--- /dev/null
+++ b/Scribe.Tests/Cache/InternPoolTests.cs
@@ -0,0 +1,42 @@
+using Scribe.Cache;
+
+namespace Scribe.Tests.Cache;
+
+public class InternPoolTests
+{
+ [Fact]
+ public void SameValue_ReturnsSameReference()
+ {
+ // Build the strings from pieces to defeat the compiler's string literal pooling,
+ // then confirm the InternPool gives us a single canonical reference.
+ var a = "scribe" + "-test-1";
+ var b = "scribe" + "-test-1";
+
+ // Guard: without interning these should be distinct references most of the time.
+ // (Not strictly guaranteed by the runtime, but for concatenated strings it holds.)
+ var interned1 = InternPool.Intern(a);
+ var interned2 = InternPool.Intern(b);
+
+ ReferenceEquals(interned1, interned2).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Null_ReturnsNull()
+ {
+ InternPool.Intern(null!).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Empty_ReturnsEmpty()
+ {
+ InternPool.Intern("").ShouldBe("");
+ }
+
+ [Fact]
+ public void DifferentValues_StayDistinct()
+ {
+ var a = InternPool.Intern("scribe-test-distinct-a");
+ var b = InternPool.Intern("scribe-test-distinct-b");
+ ReferenceEquals(a, b).ShouldBeFalse();
+ }
+}
diff --git a/Scribe.Tests/Cache/LocationInfoTests.cs b/Scribe.Tests/Cache/LocationInfoTests.cs
new file mode 100644
index 0000000..cbdea66
--- /dev/null
+++ b/Scribe.Tests/Cache/LocationInfoTests.cs
@@ -0,0 +1,86 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Cache;
+
+namespace Scribe.Tests.Cache;
+
+public class LocationInfoTests
+{
+ private static SyntaxTree Parse(string source, string path) =>
+ CSharpSyntaxTree.ParseText(
+ source,
+ path: path,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ [Fact]
+ public void From_Null_ReturnsNull()
+ {
+ LocationInfo.From(null).ShouldBeNull();
+ }
+
+ [Fact]
+ public void From_LocationNone_ReturnsNull()
+ {
+ LocationInfo.From(Location.None).ShouldBeNull();
+ }
+
+ [Fact]
+ public void From_SourceLocation_CapturesFilePath()
+ {
+ var tree = Parse("class Foo {}", "test.cs");
+ var node = tree.GetRoot(TestContext.Current.CancellationToken)
+ .DescendantNodes().First();
+ var loc = node.GetLocation();
+
+ var info = LocationInfo.From(loc);
+
+ info.ShouldNotBeNull();
+ info!.Value.FilePath.ShouldBe("test.cs");
+ }
+
+ [Fact]
+ public void From_SourceLocation_CapturesTextSpan()
+ {
+ var tree = Parse("class Foo {}", "test.cs");
+ var node = tree.GetRoot(TestContext.Current.CancellationToken)
+ .DescendantNodes().First();
+ var loc = node.GetLocation();
+
+ var info = LocationInfo.From(loc);
+
+ info!.Value.TextSpan.ShouldBe(loc.SourceSpan);
+ }
+
+ [Fact]
+ public void ToLocation_RoundTrips_FilePath()
+ {
+ var tree = Parse("class Foo {}", "/path/to/test.cs");
+ var node = tree.GetRoot(TestContext.Current.CancellationToken)
+ .DescendantNodes().First();
+ var original = node.GetLocation();
+
+ var info = LocationInfo.From(original)!.Value;
+ var materialised = info.ToLocation();
+
+ materialised.GetLineSpan().Path.ShouldBe(original.GetLineSpan().Path);
+ materialised.SourceSpan.ShouldBe(original.SourceSpan);
+ }
+
+ [Fact]
+ public void Equality_By_Value()
+ {
+ var a = new LocationInfo("a.cs", new TextSpan(0, 10), new LinePositionSpan(default, default));
+ var b = new LocationInfo("a.cs", new TextSpan(0, 10), new LinePositionSpan(default, default));
+ a.ShouldBe(b);
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void Different_Path_Not_Equal()
+ {
+ var a = new LocationInfo("a.cs", new TextSpan(0, 10), default);
+ var b = new LocationInfo("b.cs", new TextSpan(0, 10), default);
+ a.ShouldNotBe(b);
+ }
+}
diff --git a/Scribe.Tests/Cache/WellKnownTypesTests.cs b/Scribe.Tests/Cache/WellKnownTypesTests.cs
new file mode 100644
index 0000000..42ad4be
--- /dev/null
+++ b/Scribe.Tests/Cache/WellKnownTypesTests.cs
@@ -0,0 +1,76 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+
+namespace Scribe.Tests.Cache;
+
+public class WellKnownTypesTests
+{
+ private static CSharpCompilation MakeCompilation(string source = "class Foo {}") =>
+ CSharpCompilation.Create(
+ "TestAssembly",
+ syntaxTrees:
+ [
+ CSharpSyntaxTree.ParseText(
+ source,
+ cancellationToken: TestContext.Current.CancellationToken),
+ ],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ ]);
+
+ [Fact]
+ public void From_ResolvesKnownBclType()
+ {
+ var compilation = MakeCompilation();
+ var known = WellKnownTypes.From(compilation, "System.Object");
+
+ known.Get("System.Object").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void From_ReturnsNullForMissingType()
+ {
+ var compilation = MakeCompilation();
+ var known = WellKnownTypes.From(compilation, "DoesNotExist.Whatsoever");
+
+ known.Get("DoesNotExist.Whatsoever").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Get_ReturnsNullForUnregisteredName()
+ {
+ var compilation = MakeCompilation();
+ var known = WellKnownTypes.From(compilation, "System.Object");
+
+ known.Get("System.String").ShouldBeNull();
+ }
+
+ [Fact]
+ public void TryGet_ReturnsTrueForKnown_FalseForUnknown()
+ {
+ var compilation = MakeCompilation();
+ var known = WellKnownTypes.From(compilation, "System.Object");
+
+ known.TryGet("System.Object", out var obj).ShouldBeTrue();
+ obj.ShouldNotBeNull();
+
+ known.TryGet("System.String", out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Resolve_LazilyCachesNewNames()
+ {
+ var compilation = MakeCompilation();
+ var known = WellKnownTypes.From(compilation);
+
+ known.TryGet("System.String", out _).ShouldBeFalse();
+
+ var first = known.Resolve(compilation, "System.String");
+ first.ShouldNotBeNull();
+
+ known.TryGet("System.String", out var cached).ShouldBeTrue();
+ cached!.ShouldBe(first!, SymbolEqualityComparer.Default);
+ }
+}
diff --git a/Scribe.Tests/Scribe.Tests.csproj b/Scribe.Tests/Scribe.Tests.csproj
index d99fb4b..b138b25 100644
--- a/Scribe.Tests/Scribe.Tests.csproj
+++ b/Scribe.Tests/Scribe.Tests.csproj
@@ -1,9 +1,12 @@
+
+
+
diff --git a/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs
new file mode 100644
index 0000000..2426824
--- /dev/null
+++ b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Scribe.Ink.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Validates : every
+/// ShapeBuilder.Project<TModel> invocation with a TModel
+/// that stores a Roslyn reference type (ISymbol, SyntaxNode,
+/// Compilation, SemanticModel, SyntaxTree, Location,
+/// AttributeData) should emit SCRIBE101 pointing at the offending member.
+///
+public class CacheCorrectnessAnalyzerTests
+{
+ [Fact]
+ public void Flags_ISymbol_field_on_projection_model()
+ {
+ const string source = @"
+using Scribe.Shapes;
+using Microsoft.CodeAnalysis;
+
+public record struct Bad(INamedTypeSymbol Symbol);
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Bad(null!));
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE101");
+ diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture)
+ .ShouldContain("Symbol");
+ diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture)
+ .ShouldContain("ISymbol");
+ }
+
+ [Fact]
+ public void Flags_SyntaxNode_property_on_projection_model()
+ {
+ const string source = @"
+using Scribe.Shapes;
+using Microsoft.CodeAnalysis;
+
+public sealed record Bad
+{
+ public SyntaxNode? Node { get; init; }
+}
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Bad());
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE101");
+ diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture)
+ .ShouldContain("Node");
+ diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture)
+ .ShouldContain("SyntaxNode");
+ }
+
+ [Fact]
+ public void Flags_Compilation_and_Location_together()
+ {
+ const string source = @"
+using Scribe.Shapes;
+using Microsoft.CodeAnalysis;
+
+public record struct Bad(Compilation Comp, Location Loc);
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Bad(null!, null!));
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.Length.ShouldBe(2);
+ diagnostics.Select(d => d.Id).ShouldAllBe(id => id == "SCRIBE101");
+ var messages = string.Join("|", diagnostics.Select(d => d.GetMessage(System.Globalization.CultureInfo.InvariantCulture)));
+ messages.ShouldContain("Compilation");
+ messages.ShouldContain("Location");
+ }
+
+ [Fact]
+ public void Does_not_flag_cache_safe_model()
+ {
+ const string source = @"
+using Scribe.Shapes;
+using Scribe.Cache;
+
+public record struct Good(string Fqn, string Name, LocationInfo? Where);
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Good(ctx.Fqn, ""x"", null));
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Ignores_static_members_and_constants()
+ {
+ const string source = @"
+using Scribe.Shapes;
+using Microsoft.CodeAnalysis;
+
+public record struct Model(string Fqn)
+{
+ public static ISymbol? Shared = null; // static — not cached per-item
+ public const string Tag = ""x"";
+}
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Model(ctx.Fqn));
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Flags_ITypeSymbol_subinterface_via_ISymbol_base()
+ {
+ // ITypeSymbol : INamespaceOrTypeSymbol : ISymbol — inheritance must be walked.
+ const string source = @"
+using Scribe.Shapes;
+using Microsoft.CodeAnalysis;
+
+public record struct Bad(ITypeSymbol T);
+
+public class Use
+{
+ public void M()
+ {
+ var _ = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Bad(null!));
+ }
+}
+";
+ var diagnostics = RunAnalyzer(source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE101");
+ }
+
+ private static ImmutableArray RunAnalyzer(string source)
+ {
+ var parseOptions = new Microsoft.CodeAnalysis.CSharp.CSharpParseOptions(
+ Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest);
+ var tree = CSharpSyntaxTree.ParseText(source, parseOptions);
+ var refs = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
+ .ToList();
+ // Ensure Scribe + Microsoft.CodeAnalysis are present even if their .Location
+ // was empty at AppDomain scan time (bundled / single-file hosting).
+ void AddIfMissing(System.Reflection.Assembly asm)
+ {
+ if (string.IsNullOrEmpty(asm.Location)) return;
+ if (refs.OfType().Any(r => string.Equals(r.FilePath, asm.Location, System.StringComparison.OrdinalIgnoreCase))) return;
+ refs.Add(MetadataReference.CreateFromFile(asm.Location));
+ }
+ AddIfMissing(typeof(Scribe.Shapes.Shape).Assembly);
+ AddIfMissing(typeof(Scribe.Cache.LocationInfo).Assembly);
+ AddIfMissing(typeof(Microsoft.CodeAnalysis.ISymbol).Assembly);
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "TestAssembly",
+ syntaxTrees: [tree],
+ references: refs,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ var compileErrors = compilation.GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .ToList();
+ if (compileErrors.Count > 0)
+ {
+ throw new System.InvalidOperationException(
+ "Test compilation failed: "
+ + string.Join("; ", compileErrors.Select(e => e.GetMessage(System.Globalization.CultureInfo.InvariantCulture))));
+ }
+ var analyzer = new CacheCorrectnessAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult();
+ }
+}
diff --git a/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs b/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs
new file mode 100644
index 0000000..6f6faf7
--- /dev/null
+++ b/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Verifies cache correctness of by
+/// running the generator twice with a tracked driver and inspecting
+/// s. An unrelated edit must not cause
+/// the matched-pair step to re-execute — otherwise the whole point of projecting
+/// into cache-safe models is wasted.
+///
+public class RelationPairIncrementalityTests
+{
+ private const string MatchedTrackingName = "scribe-test-matched";
+
+ private readonly record struct Command(string Fqn, string RaisesEventName);
+
+ private readonly record struct Event(string Fqn, string Name);
+
+ private sealed class PairGenerator : IIncrementalGenerator
+ {
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var commandShape = Shape.Class()
+ .MustHaveAttribute("CommandAttribute")
+ .Project((in ShapeProjectionContext ctx) =>
+ new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty));
+
+ var eventShape = Shape.Class()
+ .MustHaveAttribute("EventAttribute")
+ .Project((in ShapeProjectionContext ctx) =>
+ new Event(ctx.Fqn, ctx.Fqn));
+
+ var pair = Relation.Pair(
+ commandShape.ToProvider(context),
+ eventShape.ToProvider(context),
+ c => c.RaisesEventName,
+ e => e.Name);
+
+ var tracked = pair.Matched.WithTrackingName(MatchedTrackingName);
+ context.RegisterSourceOutput(tracked, (_, _) => { });
+ }
+ }
+
+ private static CSharpCompilation MakeCompilation(string source) =>
+ CSharpCompilation.Create(
+ "TestAsm",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken)],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location),
+ ],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ private static CSharpGeneratorDriver MakeDriver() =>
+ CSharpGeneratorDriver.Create(
+ generators: [new PairGenerator().AsSourceGenerator()],
+ additionalTexts: default,
+ parseOptions: null,
+ optionsProvider: null,
+ driverOptions: new GeneratorDriverOptions(
+ disabledOutputs: IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true));
+
+ private const string Prelude = @"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+";
+
+ [Fact]
+ public void Unrelated_edit_does_not_re_materialize_matched_pairs()
+ {
+ var before = Prelude + @"
+[Command(""FooEvent"")] public class Foo {}
+[Event] public class FooEvent {}
+";
+
+ var after = before + "\npublic class Unrelated {} // add an unrelated type";
+
+ var driver = (CSharpGeneratorDriver)MakeDriver()
+ .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken);
+
+ driver = (CSharpGeneratorDriver)driver
+ .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken);
+
+ var reasons = CollectMatchedStepReasons(driver);
+
+ // Every Matched output from the second run must be Cached or Unchanged —
+ // no re-projection, no re-emission.
+ reasons.ShouldNotBeEmpty();
+ reasons.ShouldAllBe(r =>
+ r == IncrementalStepRunReason.Cached
+ || r == IncrementalStepRunReason.Unchanged);
+ }
+
+ [Fact]
+ public void Adding_a_new_matched_pair_preserves_existing_cached_outputs()
+ {
+ var before = Prelude + @"
+[Command(""FooEvent"")] public class Foo {}
+[Event] public class FooEvent {}
+";
+
+ var after = Prelude + @"
+[Command(""FooEvent"")] public class Foo {}
+[Event] public class FooEvent {}
+[Command(""BarEvent"")] public class Bar {}
+[Event] public class BarEvent {}
+";
+
+ var driver = (CSharpGeneratorDriver)MakeDriver()
+ .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken);
+
+ driver = (CSharpGeneratorDriver)driver
+ .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken);
+
+ var reasons = CollectMatchedStepReasons(driver);
+
+ // After adding Bar + BarEvent we expect:
+ // - Foo/FooEvent output: Cached or Unchanged (preserved)
+ // - Bar/BarEvent output: New (freshly produced)
+ // No existing output should be Modified (that would imply a non-equatable model).
+ reasons.ShouldContain(IncrementalStepRunReason.New);
+ reasons.ShouldNotContain(IncrementalStepRunReason.Modified);
+ }
+
+ [Fact]
+ public void Editing_command_attribute_value_re_materializes_only_its_pair()
+ {
+ var before = Prelude + @"
+[Command(""FooEvent"")] public class Foo {}
+[Event] public class FooEvent {}
+[Command(""BarEvent"")] public class Bar {}
+[Event] public class BarEvent {}
+";
+
+ // Flip Bar's raised event — Foo/FooEvent pairing is unaffected.
+ var after = Prelude + @"
+[Command(""FooEvent"")] public class Foo {}
+[Event] public class FooEvent {}
+[Command(""DifferentEvent"")] public class Bar {}
+[Event] public class BarEvent {}
+[Event] public class DifferentEvent {}
+";
+
+ var driver = (CSharpGeneratorDriver)MakeDriver()
+ .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken);
+
+ driver = (CSharpGeneratorDriver)driver
+ .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken);
+
+ var reasons = CollectMatchedStepReasons(driver);
+
+ // Foo's pair must remain cached; Bar's pair is a new output value.
+ // At least one Cached/Unchanged output proves we didn't re-run everything.
+ var preserved = reasons.Count(r =>
+ r == IncrementalStepRunReason.Cached
+ || r == IncrementalStepRunReason.Unchanged);
+ preserved.ShouldBeGreaterThan(0);
+ }
+
+ private static List CollectMatchedStepReasons(CSharpGeneratorDriver driver)
+ {
+ var result = driver.GetRunResult();
+ var reasons = new List();
+ foreach (var generatorResult in result.Results)
+ {
+ if (!generatorResult.TrackedSteps.TryGetValue(MatchedTrackingName, out var steps))
+ {
+ continue;
+ }
+
+ foreach (var step in steps)
+ {
+ foreach (var output in step.Outputs)
+ {
+ reasons.Add(output.Reason);
+ }
+ }
+ }
+
+ return reasons;
+ }
+}
diff --git a/Scribe.Tests/Shapes/RelationPairTests.cs b/Scribe.Tests/Shapes/RelationPairTests.cs
new file mode 100644
index 0000000..5eddc8f
--- /dev/null
+++ b/Scribe.Tests/Shapes/RelationPairTests.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Drives through
+/// and validates three flows:
+/// matched pairs stream, orphan-left diagnostic, orphan-right diagnostic.
+/// Grounding scenario is the Hermetic-style Command ↔ Event join: each command
+/// declares the event metadata name it raises, each event declares its own name;
+/// the pair joins on that shared key.
+///
+public class RelationPairTests
+{
+ private readonly record struct Command(string Fqn, string RaisesEventName);
+
+ private readonly record struct Event(string Fqn, string Name);
+
+ private sealed class PairGenerator : IIncrementalGenerator
+ {
+ public static readonly List> Pairs = new();
+ public static readonly List Diagnostics = new();
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var commandShape = Shape.Class()
+ .MustHaveAttribute("CommandAttribute")
+ .Project((in ShapeProjectionContext ctx) =>
+ new Command(
+ Fqn: ctx.Fqn,
+ RaisesEventName: ctx.Attribute.Ctor(0) ?? string.Empty));
+
+ var eventShape = Shape.Class()
+ .MustHaveAttribute("EventAttribute")
+ .Project((in ShapeProjectionContext ctx) =>
+ new Event(Fqn: ctx.Fqn, Name: ctx.Fqn));
+
+ var pair = Relation.Pair(
+ commandShape.ToProvider(context),
+ eventShape.ToProvider(context),
+ leftKey: c => c.RaisesEventName,
+ rightKey: e => e.Name)
+ .RequireLeftHasRight(
+ id: "SCRIBE201",
+ title: "Command raises undeclared event",
+ messageFormat: "Command '{0}' raises undeclared event '{1}'")
+ .WarnOnRightUnused(
+ id: "SCRIBE202",
+ title: "Event is declared but never raised",
+ messageFormat: "Event '{0}' is declared but never raised");
+
+ context.RegisterSourceOutput(pair.Matched, (spc, p) =>
+ {
+ lock (Pairs) { Pairs.Add(p); }
+ });
+
+ pair.RegisterDiagnostics(context);
+ }
+ }
+
+ private static CSharpCompilation MakeCompilation(string source) =>
+ CSharpCompilation.Create(
+ "TestAsm",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken)],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location),
+ ],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ private static (Microsoft.CodeAnalysis.GeneratorDriverRunResult Result, List> Pairs) Run(string source)
+ {
+ PairGenerator.Pairs.Clear();
+ PairGenerator.Diagnostics.Clear();
+ var driver = CSharpGeneratorDriver.Create(new PairGenerator())
+ .RunGenerators(MakeCompilation(source), TestContext.Current.CancellationToken);
+ return (driver.GetRunResult(), [..PairGenerator.Pairs]);
+ }
+
+ [Fact]
+ public void Matches_command_to_event_by_shared_name()
+ {
+ var (result, pairs) = Run(@"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+
+[Command(""DoThingCompleted"")] public class DoThing {}
+[Event] public class DoThingCompleted {}
+");
+ result.Diagnostics.IsEmpty.ShouldBeTrue();
+ pairs.Count.ShouldBe(1);
+ pairs[0].Left.Fqn.ShouldBe("DoThing");
+ pairs[0].Right.Fqn.ShouldBe("DoThingCompleted");
+ }
+
+ [Fact]
+ public void Emits_SCRIBE201_when_command_raises_undeclared_event()
+ {
+ var (result, pairs) = Run(@"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+
+[Command(""MissingEvent"")] public class DoThing {}
+");
+ pairs.ShouldBeEmpty();
+ var diag = result.Diagnostics.Single(d => d.Id == "SCRIBE201");
+ var msg = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture);
+ msg.ShouldContain("DoThing");
+ msg.ShouldContain("MissingEvent");
+ }
+
+ [Fact]
+ public void Emits_SCRIBE202_when_event_has_no_raising_command()
+ {
+ var (result, _) = Run(@"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+
+[Event] public class OrphanEvent {}
+");
+ var diag = result.Diagnostics.Single(d => d.Id == "SCRIBE202");
+ diag.Severity.ShouldBe(DiagnosticSeverity.Warning);
+ diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture).ShouldContain("OrphanEvent");
+ }
+
+ [Fact]
+ public void Matched_and_orphans_coexist_in_the_same_compilation()
+ {
+ var (result, pairs) = Run(@"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+
+[Command(""Matched"")] public class DoMatched {}
+[Command(""NotThere"")] public class DoOrphanCommand {}
+[Event] public class Matched {}
+[Event] public class OrphanEvent {}
+");
+ pairs.Count.ShouldBe(1);
+ pairs[0].Left.Fqn.ShouldBe("DoMatched");
+ pairs[0].Right.Fqn.ShouldBe("Matched");
+
+ var ids = result.Diagnostics.Select(d => d.Id).OrderBy(x => x).ToArray();
+ ids.ShouldBe(["SCRIBE201", "SCRIBE202"]);
+ }
+
+ [Fact]
+ public void Silent_default_emits_no_diagnostics_without_policy()
+ {
+ var silentGen = new InlineGen((context) =>
+ {
+ var left = Shape.Class()
+ .MustHaveAttribute("CommandAttribute")
+ .Project((in ShapeProjectionContext ctx) =>
+ new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty));
+ var right = Shape.Class()
+ .MustHaveAttribute("EventAttribute")
+ .Project((in ShapeProjectionContext ctx) => new Event(ctx.Fqn, ctx.Fqn));
+
+ var pair = Relation.Pair(left.ToProvider(context), right.ToProvider(context),
+ c => c.RaisesEventName, e => e.Name);
+
+ pair.RegisterDiagnostics(context);
+ context.RegisterSourceOutput(pair.Matched, (spc, _) => { });
+ });
+
+ var driver = CSharpGeneratorDriver.Create(silentGen).RunGenerators(
+ MakeCompilation(@"
+using System;
+public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} }
+public sealed class EventAttribute : Attribute {}
+
+[Command(""Nope"")] public class A {}
+[Event] public class B {}
+"),
+ TestContext.Current.CancellationToken);
+
+ driver.GetRunResult().Diagnostics.IsEmpty.ShouldBeTrue();
+ }
+
+ private sealed class InlineGen : IIncrementalGenerator
+ {
+ private readonly Action _init;
+ public InlineGen(Action init) => _init = init;
+ public void Initialize(IncrementalGeneratorInitializationContext context) => _init(context);
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs
new file mode 100644
index 0000000..97af4e1
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Validates that projects a shape's
+/// checks into a working that emits the
+/// expected diagnostics at the configured anchors
+/// with the expected fix-dispatch properties.
+///
+public class ShapeAnalyzerTests
+{
+ private static readonly string[] _scribe001 = ["SCRIBE001"];
+
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void MustBePartial_emits_SCRIBE001_at_type_keyword_on_non_partial_class()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ var diag = diagnostics[0];
+ diag.Id.ShouldBe("SCRIBE001");
+ diag.Properties["fixKind"].ShouldBe("AddPartialModifier");
+ diag.Properties["squiggleAt"].ShouldBe("TypeKeyword");
+ var text = source.Substring(diag.Location.SourceSpan.Start, diag.Location.SourceSpan.Length);
+ text.ShouldBe("class");
+ }
+
+ [Fact]
+ public void MustBePartial_emits_nothing_when_class_is_partial()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public partial class Widget { }");
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustBeSealed_emits_SCRIBE002_at_type_keyword()
+ {
+ var shape = Shape.Class()
+ .MustBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE002");
+ diagnostics[0].Properties["fixKind"].ShouldBe("AddSealedModifier");
+ }
+
+ [Fact]
+ public void Secondary_MustHaveAttribute_emits_SCRIBE004_and_encodes_attribute_name_in_properties()
+ {
+ // First MustHaveAttribute is promoted to the selector — it narrows the shape's
+ // scope to types that carry ThingAttribute. A second MustHaveAttribute runs as
+ // a regular check on those narrowed types.
+ var shape = Shape.Class()
+ .MustHaveAttribute("ThingAttribute")
+ .MustHaveAttribute("MarkerAttribute")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+public sealed class MarkerAttribute : System.Attribute { }
+[Thing] public class Widget { }
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ var diag = diagnostics[0];
+ diag.Id.ShouldBe("SCRIBE004");
+ diag.Properties["fixKind"].ShouldBe("AddAttribute");
+ diag.Properties["attribute"].ShouldBe("MarkerAttribute");
+ }
+
+ [Fact]
+ public void MustImplement_squiggles_at_base_list_when_present_and_encodes_interface()
+ {
+ var shape = Shape.Class()
+ .MustImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ var diag = diagnostics[0];
+ diag.Id.ShouldBe("SCRIBE003");
+ diag.Properties["fixKind"].ShouldBe("AddInterfaceToBaseList");
+ diag.Properties["interface"].ShouldBe("System.IDisposable");
+ var text = source.Substring(diag.Location.SourceSpan.Start, diag.Location.SourceSpan.Length);
+ text.ShouldContain("IComparable");
+ }
+
+ [Fact]
+ public void Analyzer_ignores_types_that_do_not_carry_the_primary_attribute()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("ThingAttribute")
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+public class Alpha { } // no attribute — ignored by MustBePartial
+[Thing] public class Beta { } // has attribute but not partial → SCRIBE001
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Select(d => d.Id).ShouldBe(_scribe001);
+ }
+
+ private static ImmutableArray RunAnalyzer(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ var compilation = Compile(source);
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult();
+ }
+
+ private static CSharpCompilation Compile(string source)
+ {
+ var tree = CSharpSyntaxTree.ParseText(source);
+ var refs = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
+ .ToList();
+ return CSharpCompilation.Create(
+ assemblyName: "TestAssembly",
+ syntaxTrees: [tree],
+ references: refs,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapeBuilderTests.cs b/Scribe.Tests/Shapes/ShapeBuilderTests.cs
new file mode 100644
index 0000000..566a24c
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeBuilderTests.cs
@@ -0,0 +1,286 @@
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+using Scribe.Shapes;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Unit-tests the per-primitive predicate behaviour of .
+/// Each test parses a compilation, picks out a named type, and invokes the
+/// builder's internal check pipeline via
+/// round-trips would be over-weight here — we probe the predicates directly
+/// through a projected Shape on a synthetic dummy model.
+///
+public class ShapeBuilderTests
+{
+ private readonly record struct TypeNameModel(string Name);
+
+ private static (CSharpCompilation Compilation, INamedTypeSymbol Symbol) Compile(
+ string source, string metadataName)
+ {
+ var tree = CSharpSyntaxTree.ParseText(
+ source,
+ cancellationToken: TestContext.Current.CancellationToken);
+ var compilation = CSharpCompilation.Create(
+ "TestAsm",
+ syntaxTrees: [tree],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location),
+ ]);
+
+ var symbol = compilation.GetTypeByMetadataName(metadataName);
+ symbol.ShouldNotBeNull($"type '{metadataName}' did not resolve");
+ return (compilation, symbol!);
+ }
+
+ private static INamedTypeSymbol GetType(string source, string metadataName) =>
+ Compile(source, metadataName).Symbol;
+
+ private static EquatableArray RunChecks(ShapeBuilder builder, string source, string metadataName)
+ {
+ var (compilation, symbol) = Compile(source, metadataName);
+ var ct = TestContext.Current.CancellationToken;
+ var diags = new System.Collections.Generic.List();
+
+ foreach (var check in builder.Checks)
+ {
+ if (!check.Predicate(symbol, compilation, ct))
+ {
+ diags.Add(new DiagnosticInfo(
+ Id: check.Id,
+ Severity: check.Severity,
+ MessageArgs: check.MessageArgs(symbol),
+ Location: null));
+ }
+ }
+
+ return EquatableArray.From(diags);
+ }
+
+ // Convenience overload for tests that already hold a compiled symbol.
+ private static EquatableArray RunChecks(ShapeBuilder builder, INamedTypeSymbol symbol)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var tree = symbol.DeclaringSyntaxReferences[0].SyntaxTree;
+
+ // Reuse the compilation the tree already belongs to by locating it via the symbol's containing assembly.
+ // When symbols come from our Compile(...) helper, the compilation is the one we built.
+ var compilation = CSharpCompilation.Create(
+ "Probe",
+ syntaxTrees: [tree],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location),
+ ]);
+ var reResolved = compilation.GetTypeByMetadataName(symbol.ToDisplayString())!;
+
+ var diags = new System.Collections.Generic.List();
+ foreach (var check in builder.Checks)
+ {
+ if (!check.Predicate(reResolved, compilation, ct))
+ {
+ diags.Add(new DiagnosticInfo(
+ Id: check.Id,
+ Severity: check.Severity,
+ MessageArgs: check.MessageArgs(reResolved),
+ Location: null));
+ }
+ }
+
+ return EquatableArray.From(diags);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // MustBePartial
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustBePartial_PassesForPartialClass()
+ {
+ var symbol = GetType("public partial class Foo {}", "Foo");
+ var builder = Shape.Class().MustBePartial();
+ RunChecks(builder, symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MustBePartial_FailsForNonPartialClass()
+ {
+ var symbol = GetType("public class Foo {}", "Foo");
+ var builder = Shape.Class().MustBePartial();
+
+ var diags = RunChecks(builder, symbol);
+ diags.Count.ShouldBe(1);
+ diags[0].Id.ShouldBe("SCRIBE001");
+ diags[0].Severity.ShouldBe(DiagnosticSeverity.Error);
+ }
+
+ [Fact]
+ public void MustBePartial_UsesOverrideId()
+ {
+ var symbol = GetType("public class Foo {}", "Foo");
+ var builder = Shape.Class().MustBePartial(new DiagnosticSpec(Id: "CUSTOM01"));
+
+ RunChecks(builder, symbol)[0].Id.ShouldBe("CUSTOM01");
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // MustBeSealed
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustBeSealed_PassesForSealedClass()
+ {
+ var symbol = GetType("public sealed class Foo {}", "Foo");
+ RunChecks(Shape.Class().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MustBeSealed_FailsForOpenClass()
+ {
+ var symbol = GetType("public class Foo {}", "Foo");
+ var diags = RunChecks(Shape.Class().MustBeSealed(), symbol);
+
+ diags.Count.ShouldBe(1);
+ diags[0].Id.ShouldBe("SCRIBE002");
+ }
+
+ [Fact]
+ public void MustBeSealed_PassesForValueType()
+ {
+ var symbol = GetType("public struct Foo {}", "Foo");
+ RunChecks(Shape.Struct().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // MustImplement
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustImplement_PassesWhenInterfaceImplemented()
+ {
+ var symbol = GetType(
+ "using System; public class Foo : IDisposable { public void Dispose() {} }",
+ "Foo");
+ RunChecks(Shape.Class().MustImplement(), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MustImplement_FailsWhenInterfaceMissing()
+ {
+ var symbol = GetType("public class Foo {}", "Foo");
+ var diags = RunChecks(Shape.Class().MustImplement(), symbol);
+
+ diags.Count.ShouldBe(1);
+ diags[0].Id.ShouldBe("SCRIBE003");
+ }
+
+ [Fact]
+ public void MustImplement_ByMetadataName_Works()
+ {
+ var symbol = GetType(
+ "using System; public class Foo : IDisposable { public void Dispose() {} }",
+ "Foo");
+ RunChecks(Shape.Class().MustImplement("System.IDisposable"), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // MustHaveAttribute
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustHaveAttribute_PassesWhenAttributePresent()
+ {
+ var symbol = GetType("""
+ using System;
+ public sealed class FooAttribute : Attribute {}
+ [Foo] public class Target {}
+ """, "Target");
+
+ RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MustHaveAttribute_FailsWhenAttributeMissing()
+ {
+ var symbol = GetType("public class Target {}", "Target");
+ var diags = RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol);
+
+ diags.Count.ShouldBe(1);
+ diags[0].Id.ShouldBe("SCRIBE004");
+ }
+
+ [Fact]
+ public void MustHaveAttribute_SetsPrimaryAttributeMetadataName()
+ {
+ var builder = Shape.Class().MustHaveAttribute("FooAttribute");
+ builder.PrimaryAttributeMetadataName.ShouldBe("FooAttribute");
+ }
+
+ [Fact]
+ public void MustHaveAttribute_SecondCall_DoesNotOverridePrimary()
+ {
+ var builder = Shape.Class()
+ .MustHaveAttribute("FirstAttribute")
+ .MustHaveAttribute("SecondAttribute");
+ builder.PrimaryAttributeMetadataName.ShouldBe("FirstAttribute");
+ builder.Checks.Count.ShouldBe(2);
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // MustBeNamed
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustBeNamed_PassesWhenMatches()
+ {
+ var symbol = GetType("public class FooHandler {}", "FooHandler");
+ RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol).IsEmpty.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MustBeNamed_FailsWhenDoesNotMatch()
+ {
+ var symbol = GetType("public class FooManager {}", "FooManager");
+ var diags = RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol);
+
+ diags.Count.ShouldBe(1);
+ diags[0].Id.ShouldBe("SCRIBE005");
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Fluent chaining
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MultipleChecks_AllFail_YieldsAllDiagnostics()
+ {
+ var symbol = GetType("public class Foo {}", "Foo");
+ var builder = Shape.Class()
+ .MustBePartial()
+ .MustBeSealed()
+ .MustBeNamed(".*Handler$");
+
+ var diags = RunChecks(builder, symbol);
+ diags.Count.ShouldBe(3);
+ var ids = diags.AsSpan().ToArray().Select(d => d.Id).ToHashSet();
+ ids.ShouldBe(new System.Collections.Generic.HashSet { "SCRIBE001", "SCRIBE002", "SCRIBE005" });
+ }
+
+ [Fact]
+ public void ProjectSealsBuilder_IntoTypedShape()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new TypeNameModel(ctx.Symbol.Name));
+
+ shape.ShouldNotBeNull();
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapeFixProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs
new file mode 100644
index 0000000..a675ac0
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+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.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Ink.Shapes;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Validates that dispatches
+/// each to the correct code action and produces the
+/// expected source transformation.
+///
+public class ShapeFixProviderTests
+{
+ private static readonly string[] _allThreeIds = ["SCRIBE001", "SCRIBE002", "SCRIBE003"];
+
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public async Task AddPartialModifier_adds_partial_keyword()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public class Widget { }");
+ result.ShouldContain("partial class Widget");
+ }
+
+ [Fact]
+ public async Task AddSealedModifier_adds_sealed_keyword_before_partial()
+ {
+ var shape = Shape.Class()
+ .MustBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public partial class Widget { }");
+ result.ShouldContain("sealed partial class Widget");
+ }
+
+ [Fact]
+ public async Task AddInterfaceToBaseList_appends_interface_when_base_list_absent()
+ {
+ var shape = Shape.Class()
+ .MustImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public sealed class Widget { public void Dispose() { } }");
+ result.ShouldContain(": System.IDisposable");
+ }
+
+ [Fact]
+ public async Task AddInterfaceToBaseList_appends_to_existing_base_list()
+ {
+ var shape = Shape.Class()
+ .MustImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; public void Dispose() { } }";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldContain("IComparable");
+ result.ShouldContain("IDisposable");
+ }
+
+ [Fact]
+ public async Task AddAttribute_prepends_attribute_without_Attribute_suffix()
+ {
+ // Primary selector is ThingAttribute; Marker is the secondary must-have that fires.
+ var shape = Shape.Class()
+ .MustHaveAttribute("ThingAttribute")
+ .MustHaveAttribute("MarkerAttribute")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+public sealed class MarkerAttribute : System.Attribute { }
+[Thing] public class Widget { }
+";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldContain("[Marker]");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public async Task ToFixProvider_reports_all_check_ids_as_fixable()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .MustBeSealed()
+ .MustImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var provider = shape.ToFixProvider();
+ provider.FixableDiagnosticIds.ShouldBe(_allThreeIds);
+ }
+
+ private static async Task ApplyFirstFix(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ using var workspace = new AdhocWorkspace();
+ var project = workspace.AddProject("P", LanguageNames.CSharp)
+ .WithMetadataReferences(AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)));
+ var document = project.AddDocument("T.cs", SourceText.From(source));
+ project = document.Project;
+
+ var compilation = await project.GetCompilationAsync().ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Compilation unavailable.");
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var first = diagnostics.OrderBy(d => d.Location.SourceSpan.Start).First();
+
+ var fixProvider = shape.ToFixProvider();
+ var actions = new List();
+ var context = new CodeFixContext(
+ document,
+ first,
+ (action, _) => actions.Add(action),
+ CancellationToken.None);
+ await fixProvider.RegisterCodeFixesAsync(context).ConfigureAwait(false);
+
+ actions.Count.ShouldBeGreaterThan(0);
+
+ var operations = await actions[0].GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
+ var applied = operations.OfType().First();
+ var newDoc = applied.ChangedSolution.GetDocument(document.Id)!;
+ var text = await newDoc.GetTextAsync().ConfigureAwait(false);
+ return text.ToString();
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs
new file mode 100644
index 0000000..18abae6
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Validates the Scriptorium-generated severity variants:
+/// Should* demotes the default to
+/// , Could* demotes to
+/// . All three variants share the same
+/// diagnostic id as the Must* primitive, so #pragma warning disable
+/// silences them uniformly. A caller-supplied severity in the spec wins.
+///
+public class ShapeSeverityVariantsTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void ShouldBePartial_reports_SCRIBE001_at_Warning_severity()
+ {
+ var shape = Shape.Class()
+ .ShouldBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE001");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ }
+
+ [Fact]
+ public void CouldBePartial_reports_SCRIBE001_at_Info_severity()
+ {
+ var shape = Shape.Class()
+ .CouldBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE001");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Info);
+ }
+
+ [Fact]
+ public void MustBePartial_ShouldBePartial_and_CouldBePartial_all_share_SCRIBE001()
+ {
+ var must = Shape.Class().MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+ var should = Shape.Class().ShouldBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+ var could = Shape.Class().CouldBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ const string source = "public class Widget { }";
+
+ RunAnalyzer(must, source).Single().Id.ShouldBe("SCRIBE001");
+ RunAnalyzer(should, source).Single().Id.ShouldBe("SCRIBE001");
+ RunAnalyzer(could, source).Single().Id.ShouldBe("SCRIBE001");
+ }
+
+ [Fact]
+ public void Pragma_warning_disable_SCRIBE001_silences_the_ShouldBe_variant()
+ {
+ var shape = Shape.Class()
+ .ShouldBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ const string source = @"
+#pragma warning disable SCRIBE001
+public class Widget { }
+#pragma warning restore SCRIBE001
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Caller_supplied_severity_wins_over_variant_default()
+ {
+ // ShouldBePartial defaults to Warning, but the caller pins Hidden.
+ var shape = Shape.Class()
+ .ShouldBePartial(new DiagnosticSpec(Severity: DiagnosticSeverity.Hidden))
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Hidden);
+ }
+
+ [Fact]
+ public void ShouldImplement_generic_variant_flows_through_type_parameter_constraint()
+ {
+ // Exercises the Scriptorium-generated generic overload with its
+ // `where T : class` constraint, plus severity demotion.
+ var shape = Shape.Class()
+ .ShouldImplement()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE003");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ diagnostics[0].Properties["interface"].ShouldBe("System.IDisposable");
+ }
+
+ private static ImmutableArray RunAnalyzer(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ var compilation = Compile(source);
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult();
+ }
+
+ private static CSharpCompilation Compile(string source)
+ {
+ var tree = CSharpSyntaxTree.ParseText(source);
+ var refs = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
+ .ToList();
+ return CSharpCompilation.Create(
+ assemblyName: "TestAssembly",
+ syntaxTrees: [tree],
+ references: refs,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapeToProviderTests.cs b/Scribe.Tests/Shapes/ShapeToProviderTests.cs
new file mode 100644
index 0000000..6c66992
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeToProviderTests.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+using Scribe.Shapes;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Drives an incremental generator built on
+/// through and asserts the projected
+/// s surface via a captured output.
+///
+public class ShapeToProviderTests
+{
+ private readonly record struct CollectedModel(string Fqn, int ViolationCount);
+
+ private sealed class CapturingGenerator : IIncrementalGenerator
+ {
+ public static readonly List> Captured = new();
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("ThingAttribute")
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new CollectedModel(
+ Fqn: ctx.Fqn,
+ ViolationCount: 0));
+
+ var provider = shape.ToProvider(context);
+
+ context.RegisterSourceOutput(provider, (spc, value) =>
+ {
+ lock (Captured)
+ {
+ Captured.Add(value);
+ }
+
+ spc.AddSource(
+ $"{value.Model.Fqn.Replace('.', '_').Replace('<', '_').Replace('>', '_')}.g.cs",
+ $"// collected: {value.Fqn}, violations: {value.Violations.Count}");
+ });
+ }
+ }
+
+ private sealed class NoAttributeGenerator : IIncrementalGenerator
+ {
+ public static readonly List> Captured = new();
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new CollectedModel(
+ Fqn: ctx.Fqn,
+ ViolationCount: 0));
+
+ context.RegisterSourceOutput(shape.ToProvider(context), (spc, value) =>
+ {
+ lock (Captured)
+ {
+ Captured.Add(value);
+ }
+ });
+ }
+ }
+
+ private static CSharpCompilation MakeCompilation(string source)
+ {
+ return CSharpCompilation.Create(
+ "TestAsm",
+ syntaxTrees:
+ [
+ CSharpSyntaxTree.ParseText(
+ source,
+ cancellationToken: TestContext.Current.CancellationToken),
+ ],
+ references:
+ [
+ MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location),
+ MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location),
+ ],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ [Fact]
+ public void ToProvider_WithAttribute_CollectsMatchingTypes_AndFlagsViolations()
+ {
+ CapturingGenerator.Captured.Clear();
+
+ var compilation = MakeCompilation("""
+ using System;
+ public sealed class ThingAttribute : Attribute {}
+
+ [Thing] public partial class Alpha {}
+ [Thing] public class Beta {} // not partial -> violation
+ public partial class Gamma {} // not annotated -> ignored
+ """);
+
+ var driver = CSharpGeneratorDriver.Create(new CapturingGenerator());
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(
+ compilation, TestContext.Current.CancellationToken);
+
+ var result = driver.GetRunResult();
+ result.Diagnostics.IsEmpty.ShouldBeTrue();
+
+ CapturingGenerator.Captured.Count.ShouldBe(2);
+ var alpha = CapturingGenerator.Captured.Single(m => m.Fqn == "Alpha");
+ alpha.Violations.IsEmpty.ShouldBeTrue();
+
+ var beta = CapturingGenerator.Captured.Single(m => m.Fqn == "Beta");
+ beta.Violations.Count.ShouldBe(1);
+ beta.Violations[0].Id.ShouldBe("SCRIBE001");
+ }
+
+ [Fact]
+ public void ToProvider_WithoutAttribute_CollectsAllClassKinds()
+ {
+ NoAttributeGenerator.Captured.Clear();
+
+ var compilation = MakeCompilation("""
+ public partial class Alpha {}
+ public class Beta {} // flagged
+ public struct Gamma {} // wrong kind, filtered out
+ public interface IDelta {} // wrong kind, filtered out
+ """);
+
+ var driver = CSharpGeneratorDriver.Create(new NoAttributeGenerator());
+ driver = (CSharpGeneratorDriver)driver.RunGenerators(
+ compilation, TestContext.Current.CancellationToken);
+
+ NoAttributeGenerator.Captured.Count.ShouldBe(2);
+ NoAttributeGenerator.Captured.Select(c => c.Fqn).OrderBy(x => x)
+ .ShouldBe(["Alpha", "Beta"]);
+ }
+
+ [Fact]
+ public void ToProvider_ProjectionReceivesAttributeReader()
+ {
+ var captured = new List();
+
+ var generator = new InlineGenerator(ctx =>
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("FooAttribute")
+ .Project((in ShapeProjectionContext pc) => new ValueModel(
+ pc.Fqn,
+ pc.Attribute.Ctor(0) ?? ""));
+
+ ctx.RegisterSourceOutput(shape.ToProvider(ctx), (spc, v) =>
+ {
+ captured.Add($"{v.Model.Fqn}:{v.Model.Value}");
+ });
+ });
+
+ var compilation = MakeCompilation("""
+ using System;
+ public sealed class FooAttribute : Attribute { public FooAttribute(string v) {} }
+ [Foo("hello")] public class Target {}
+ """);
+
+ CSharpGeneratorDriver.Create(generator).RunGenerators(
+ compilation, TestContext.Current.CancellationToken);
+
+ captured.ShouldContain("Target:hello");
+ }
+
+ private readonly record struct ValueModel(string Fqn, string Value);
+
+ private sealed class InlineGenerator : IIncrementalGenerator
+ {
+ private readonly Action _init;
+ public InlineGenerator(Action init) => _init = init;
+ public void Initialize(IncrementalGeneratorInitializationContext context) => _init(context);
+ }
+}
diff --git a/Scribe.slnx b/Scribe.slnx
index ce85d1b..3ba87c9 100644
--- a/Scribe.slnx
+++ b/Scribe.slnx
@@ -1,5 +1,7 @@
+
+
diff --git a/Scribe/Attributes/AttributeReader.cs b/Scribe/Attributes/AttributeReader.cs
new file mode 100644
index 0000000..05e5fa0
--- /dev/null
+++ b/Scribe/Attributes/AttributeReader.cs
@@ -0,0 +1,264 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Scribe.Cache;
+
+namespace Scribe.Attributes;
+
+///
+/// Stack-only, typed reader over a single . Obtain via
+/// or the
+/// callback supplied to .
+///
+///
+///
+/// This is a ref struct: it pins the underlying
+/// (which holds symbol references) and must not escape the current stack frame.
+/// Read what you need, project into a cache-safe model, let the reader go out of scope.
+///
+///
+/// All typed-read methods silently return default when the requested slot is
+/// missing, out of range, or the value is not convertible to the requested type —
+/// and push a into .
+///
+///
+public ref struct AttributeReader
+{
+ private readonly AttributeData? _attribute;
+ private List? _errors;
+
+ internal AttributeReader(AttributeData? attribute)
+ {
+ _attribute = attribute;
+ _errors = null;
+ }
+
+ /// Whether a matching attribute was found on the inspected symbol.
+ public readonly bool Exists => _attribute is not null;
+
+ /// Location of the attribute application syntax, if available.
+ public readonly LocationInfo? Location =>
+ _attribute is null ? null : LocationInfo.From(GetAttributeLocation(_attribute));
+
+ ///
+ /// Number of constructor arguments supplied at the call site. Zero if the attribute
+ /// is missing.
+ ///
+ public readonly int CtorArgCount =>
+ _attribute?.ConstructorArguments.Length ?? 0;
+
+ ///
+ /// Read constructor argument as .
+ /// Emits a into and returns
+ /// default if the index is out of range or the value is not a
+ /// .
+ ///
+ public T? Ctor(int index)
+ {
+ if (_attribute is null)
+ {
+ return default;
+ }
+
+ var args = _attribute.ConstructorArguments;
+ if ((uint)index >= (uint)args.Length)
+ {
+ PushError(
+ DiagnosticIds.AttributeCtorMissing,
+ DiagnosticSeverity.Warning,
+ EquatableArray.Create(index.ToString(), _attribute.AttributeClass?.Name ?? "?"));
+ return default;
+ }
+
+ return Coerce(args[index], $"ctor[{index}]");
+ }
+
+ ///
+ /// Read constructor argument as ;
+ /// returns default silently (no diagnostic) if the index is out of range.
+ /// A type mismatch still emits a diagnostic.
+ ///
+ public T? CtorOpt(int index)
+ {
+ if (_attribute is null)
+ {
+ return default;
+ }
+
+ var args = _attribute.ConstructorArguments;
+ if ((uint)index >= (uint)args.Length)
+ {
+ return default;
+ }
+
+ return Coerce(args[index], $"ctor[{index}]");
+ }
+
+ ///
+ /// Read the named argument (property or field) as
+ /// , returning if the argument
+ /// is absent. A type mismatch emits a diagnostic and returns .
+ ///
+ public T Named(string name, T fallback)
+ {
+ if (_attribute is null || string.IsNullOrEmpty(name))
+ {
+ return fallback;
+ }
+
+ foreach (var pair in _attribute.NamedArguments)
+ {
+ if (!string.Equals(pair.Key, name, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var coerced = Coerce(pair.Value, name);
+ return coerced is null ? fallback : coerced;
+ }
+
+ return fallback;
+ }
+
+ ///
+ /// Read the -th generic type argument of the attribute
+ /// (e.g., [Foo<Bar>] → TypeArg(0) == Bar). Returns
+ /// and emits a diagnostic if the index is out of range.
+ ///
+ public ITypeSymbol? TypeArg(int index)
+ {
+ if (_attribute is null)
+ {
+ return null;
+ }
+
+ var typeArgs = _attribute.AttributeClass?.TypeArguments ?? ImmutableArray.Empty;
+ if ((uint)index >= (uint)typeArgs.Length)
+ {
+ PushError(
+ DiagnosticIds.AttributeTypeArgMissing,
+ DiagnosticSeverity.Warning,
+ EquatableArray.Create(index.ToString(), _attribute.AttributeClass?.Name ?? "?"));
+ return null;
+ }
+
+ return typeArgs[index];
+ }
+
+ ///
+ /// Return the accumulated diagnostics and reset the internal buffer. Intended to be
+ /// called once at the end of a projection.
+ ///
+ public EquatableArray DrainErrors()
+ {
+ if (_errors is null || _errors.Count == 0)
+ {
+ return EquatableArray.Empty;
+ }
+
+ var array = EquatableArray.From(_errors);
+ _errors = null;
+ return array;
+ }
+
+ private T? Coerce(TypedConstant value, string slotDescription)
+ {
+ if (value.IsNull)
+ {
+ return default;
+ }
+
+ // Array values: project each element.
+ if (value.Kind == TypedConstantKind.Array)
+ {
+ if (typeof(T).IsArray)
+ {
+ var elementType = typeof(T).GetElementType()!;
+ var source = value.Values;
+ var array = Array.CreateInstance(elementType, source.Length);
+ for (var i = 0; i < source.Length; i++)
+ {
+ var element = source[i].Value;
+ if (element is not null && elementType.IsAssignableFrom(element.GetType()))
+ {
+ array.SetValue(element, i);
+ }
+ }
+
+ return (T)(object)array;
+ }
+ }
+
+ // Type arguments: TypedConstant of kind Type carries an ITypeSymbol in .Value.
+ if (value.Kind == TypedConstantKind.Type)
+ {
+ if (value.Value is T typed)
+ {
+ return typed;
+ }
+ }
+
+ if (value.Value is T direct)
+ {
+ return direct;
+ }
+
+ // Enums are boxed as their underlying integral value.
+ if (value.Kind == TypedConstantKind.Enum && value.Value is not null)
+ {
+ try
+ {
+ return (T)value.Value;
+ }
+ catch (InvalidCastException)
+ {
+ // fall through to mismatch path
+ }
+ }
+
+ PushError(
+ DiagnosticIds.AttributeValueMismatch,
+ DiagnosticSeverity.Warning,
+ EquatableArray.Create(slotDescription, typeof(T).Name));
+ return default;
+ }
+
+ private void PushError(string id, DiagnosticSeverity severity, EquatableArray args)
+ {
+ _errors ??= new List();
+ _errors.Add(new DiagnosticInfo(
+ Id: id,
+ Severity: severity,
+ MessageArgs: args,
+ Location: Location));
+ }
+
+ private static Location? GetAttributeLocation(AttributeData attribute)
+ {
+ var reference = attribute.ApplicationSyntaxReference;
+ if (reference is null)
+ {
+ return null;
+ }
+
+ return Microsoft.CodeAnalysis.Location.Create(reference.SyntaxTree, reference.Span);
+ }
+}
+
+///
+/// Well-known diagnostic IDs emitted by when typed reads
+/// fail. Consumers can register matching s if they
+/// wish to surface these to the user; otherwise the diagnostics are informational only.
+///
+public static class DiagnosticIds
+{
+ /// Constructor argument index out of range.
+ public const string AttributeCtorMissing = "SCRIBE100";
+
+ /// Constructor / named argument value did not match the requested type.
+ public const string AttributeValueMismatch = "SCRIBE101";
+
+ /// Generic type-argument index out of range on the attribute class.
+ public const string AttributeTypeArgMissing = "SCRIBE102";
+}
diff --git a/Scribe/Attributes/AttributeSchema.cs b/Scribe/Attributes/AttributeSchema.cs
new file mode 100644
index 0000000..92bd814
--- /dev/null
+++ b/Scribe/Attributes/AttributeSchema.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Scribe.Cache;
+
+namespace Scribe.Attributes;
+
+///
+/// Typed, cache-safe single-attribute reader. Replaces the manual
+/// ConstructorArguments[i].Value is string pattern that every Roslyn
+/// source generator reinvents, with a small declarative projection surface.
+///
+///
+///
+/// Usage — ad-hoc read for a transient stage:
+///
+///
+/// var reader = AttributeSchema.For(symbol, "My.Ns.FooAttribute");
+/// if (reader.Exists)
+/// {
+/// var name = reader.Ctor<string>(0);
+/// var max = reader.Named<int>("Max", 100);
+/// }
+///
+///
+/// Usage — cache-safe projection into a user model, with collected diagnostics:
+///
+///
+/// var result = AttributeSchema.Read(symbol, "My.Ns.FooAttribute", (ref AttributeReader r) =>
+/// new MyModel(
+/// Name: r.Ctor<string>(0),
+/// Format: r.Named<string>("Format", "default")));
+/// // result.Exists, result.Model, result.Errors, result.Location
+///
+///
+public static class AttributeSchema
+{
+ ///
+ /// Return a scoped to the first attribute on
+ /// whose class FQN equals .
+ /// If no match is found, the returned reader's
+ /// is and all typed reads return default.
+ ///
+ /// The symbol to inspect.
+ ///
+ /// Fully-qualified type name of the attribute, with or without the Attribute
+ /// suffix — both forms are accepted. Matched via the attribute class's display string.
+ ///
+ public static AttributeReader For(ISymbol symbol, string attributeFqn)
+ {
+ var attribute = FindAttribute(symbol, attributeFqn);
+ return new AttributeReader(attribute);
+ }
+
+ ///
+ /// Project the first matching attribute into a cache-safe model. Collects any typed-read
+ /// mismatches into .
+ ///
+ /// The projection target. Constrain to so the result can flow through the incremental cache.
+ public static AttributeReadResult Read(
+ ISymbol symbol,
+ string attributeFqn,
+ AttributeProjection project)
+ where TModel : IEquatable
+ {
+ if (project is null)
+ {
+ throw new ArgumentNullException(nameof(project));
+ }
+
+ var attribute = FindAttribute(symbol, attributeFqn);
+ var reader = new AttributeReader(attribute);
+ var model = project(ref reader);
+
+ return new AttributeReadResult(
+ Model: model,
+ Exists: reader.Exists,
+ Location: reader.Location,
+ Errors: reader.DrainErrors());
+ }
+
+ /// Convenience — if carries the named attribute.
+ public static bool Has(ISymbol symbol, string attributeFqn) =>
+ FindAttribute(symbol, attributeFqn) is not null;
+
+ private static AttributeData? FindAttribute(ISymbol symbol, string attributeFqn)
+ {
+ if (symbol is null || string.IsNullOrEmpty(attributeFqn))
+ {
+ return null;
+ }
+
+ var normalized = Normalize(attributeFqn);
+
+ foreach (var attribute in symbol.GetAttributes())
+ {
+ var fqn = attribute.AttributeClass?.ToDisplayString();
+ if (fqn is null)
+ {
+ continue;
+ }
+
+ // Generic attributes like [Foo] produce a display string with type args;
+ // strip them for the match so callers can pass the bare name.
+ var fqnBase = StripTypeArgs(fqn);
+
+ if (string.Equals(fqn, attributeFqn, StringComparison.Ordinal)
+ || string.Equals(fqnBase, attributeFqn, StringComparison.Ordinal)
+ || string.Equals(Normalize(fqn), normalized, StringComparison.Ordinal)
+ || string.Equals(Normalize(fqnBase), normalized, StringComparison.Ordinal))
+ {
+ return attribute;
+ }
+ }
+
+ return null;
+ }
+
+ // Strip a trailing "Attribute" suffix so both "Foo" and "FooAttribute" match the class "FooAttribute".
+ private static string Normalize(string fqn)
+ {
+ const string Suffix = "Attribute";
+ if (fqn.Length > Suffix.Length && fqn.EndsWith(Suffix, StringComparison.Ordinal))
+ {
+ return fqn.Substring(0, fqn.Length - Suffix.Length);
+ }
+
+ return fqn;
+ }
+
+ private static string StripTypeArgs(string fqn)
+ {
+ var bracket = fqn.IndexOf('<');
+ return bracket > 0 ? fqn.Substring(0, bracket) : fqn;
+ }
+}
+
+/// Projection delegate for .
+public delegate TModel AttributeProjection(ref AttributeReader reader);
+
+///
+/// Cache-safe result of .
+/// All fields are pipeline-safe.
+///
+public readonly record struct AttributeReadResult(
+ TModel Model,
+ bool Exists,
+ LocationInfo? Location,
+ EquatableArray Errors)
+ where TModel : IEquatable;
diff --git a/Scribe/Cache/DiagnosticInfo.cs b/Scribe/Cache/DiagnosticInfo.cs
new file mode 100644
index 0000000..e5050dd
--- /dev/null
+++ b/Scribe/Cache/DiagnosticInfo.cs
@@ -0,0 +1,50 @@
+using Microsoft.CodeAnalysis;
+
+namespace Scribe.Cache;
+
+///
+/// A cache-safe record of a diagnostic — the ID, severity, message arguments, and
+/// location — without holding references to a or
+/// .
+///
+///
+///
+/// Flows through the incremental pipeline alongside the symbol data it accompanies.
+/// At the emission stage, callers combine it with a user-provided
+/// (which contains non-cacheable state) and
+/// materialise a real via .
+///
+///
+public readonly record struct DiagnosticInfo(
+ string Id,
+ DiagnosticSeverity Severity,
+ EquatableArray MessageArgs,
+ LocationInfo? Location)
+{
+ ///
+ /// Combine this cache-safe record with the user-supplied descriptor and produce a
+ /// real for reporting.
+ ///
+ ///
+ /// The descriptor to use. Its should match
+ /// — this is not enforced here, callers are responsible.
+ ///
+ public Diagnostic Materialize(DiagnosticDescriptor descriptor)
+ {
+ var location = Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None;
+ return MessageArgs.IsEmpty
+ ? Diagnostic.Create(descriptor, location)
+ : Diagnostic.Create(descriptor, location, ToObjectArray(MessageArgs));
+ }
+
+ private static object?[] ToObjectArray(EquatableArray args)
+ {
+ var result = new object?[args.Count];
+ for (var i = 0; i < args.Count; i++)
+ {
+ result[i] = args[i];
+ }
+
+ return result;
+ }
+}
diff --git a/Scribe/Cache/EquatableArray.cs b/Scribe/Cache/EquatableArray.cs
new file mode 100644
index 0000000..d1c976e
--- /dev/null
+++ b/Scribe/Cache/EquatableArray.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace Scribe.Cache;
+
+///
+/// An that participates in structural, element-wise
+/// value equality — the form required by the Roslyn incremental-generator cache.
+///
+///
+///
+/// is a struct wrapping a T[]; its built-in
+/// compares the underlying array reference,
+/// not its contents. Any value that flows through an
+/// IncrementalValuesProvider<T> must compare by value, otherwise the
+/// cache is invalidated on every compilation and the generator runs on every keystroke.
+///
+///
+/// wraps , computes a
+/// deterministic element-wise hash once at construction time (cached in a field so
+/// is a constant-time field read), and overrides
+/// to compare element-by-element via
+/// .
+///
+///
+/// default(EquatableArray<T>) is normalised to an empty array (same hash,
+/// equal to ) — callers never need to null-check before use.
+///
+///
+/// Element type. Must implement .
+public readonly struct EquatableArray : IEquatable>, IReadOnlyList
+ where T : IEquatable
+{
+ /// Canonical empty instance. Equal to default(EquatableArray<T>).
+ public static readonly EquatableArray Empty = new(ImmutableArray.Empty);
+
+ private readonly ImmutableArray _array;
+ private readonly int _hash;
+
+ /// Wrap an existing .
+ /// Source array. default and empty are treated identically.
+ public EquatableArray(ImmutableArray array)
+ {
+ if (array.IsDefaultOrEmpty)
+ {
+ _array = ImmutableArray.Empty;
+ _hash = EmptyHash;
+ }
+ else
+ {
+ _array = array;
+ _hash = ComputeHash(array);
+ }
+ }
+
+ /// Number of elements.
+ public int Count => _array.IsDefault ? 0 : _array.Length;
+
+ /// if there are zero elements.
+ public bool IsEmpty => _array.IsDefaultOrEmpty;
+
+ /// Element at the given zero-based index.
+ public T this[int index] => _array[index];
+
+ /// Allocation-free read-only span over the elements.
+ public ReadOnlySpan AsSpan() => _array.IsDefault ? [] : _array.AsSpan();
+
+ /// Structural, element-wise equality.
+ public bool Equals(EquatableArray other)
+ {
+ if (_hash != other._hash)
+ {
+ return false;
+ }
+
+ var left = AsSpan();
+ var right = other.AsSpan();
+ if (left.Length != right.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Length; i++)
+ {
+ if (!EqualityComparer.Default.Equals(left[i], right[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _hash;
+
+ /// Enumerate elements.
+ public IEnumerator GetEnumerator() =>
+ _array.IsDefault
+ ? Enumerable.Empty().GetEnumerator()
+ : ((IEnumerable)_array).GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ /// Structural equality operator.
+ public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right);
+
+ /// Structural inequality operator.
+ public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right);
+
+ /// Implicit conversion from .
+ public static implicit operator EquatableArray(ImmutableArray array) => new(array);
+
+ // ─── internal ─────────────────────────────────────────────────────────────
+
+ private const int EmptyHash = 0;
+
+ private static int ComputeHash(ImmutableArray array)
+ {
+ // FNV-1a over element hashes. Deterministic, no System.HashCode dependency
+ // (not guaranteed on netstandard2.0).
+ const uint seed = 2166136261u;
+ const uint step = 16777619u;
+ var hash = seed;
+ foreach (var item in array)
+ {
+ var itemHash = (uint)(item?.GetHashCode() ?? 0);
+ hash = unchecked((hash ^ itemHash) * step);
+ }
+
+ return unchecked((int)hash);
+ }
+}
+
+/// Factory helpers for .
+public static class EquatableArray
+{
+ /// Create from an existing .
+ public static EquatableArray Create(ImmutableArray source)
+ where T : IEquatable => new(source);
+
+ /// Create from a params array.
+ public static EquatableArray Create(params T[] items)
+ where T : IEquatable =>
+ items is null || items.Length == 0
+ ? EquatableArray.Empty
+ : new EquatableArray(ImmutableArray.Create(items));
+
+ /// Create from an arbitrary enumerable.
+ public static EquatableArray From(IEnumerable source)
+ where T : IEquatable
+ {
+ if (source is null)
+ {
+ return EquatableArray.Empty;
+ }
+
+ if (source is ImmutableArray immutable)
+ {
+ return new EquatableArray(immutable);
+ }
+
+ return new EquatableArray(source.ToImmutableArray());
+ }
+}
diff --git a/Scribe/Cache/InternPool.cs b/Scribe/Cache/InternPool.cs
new file mode 100644
index 0000000..d55a351
--- /dev/null
+++ b/Scribe/Cache/InternPool.cs
@@ -0,0 +1,46 @@
+using System.Collections.Concurrent;
+
+namespace Scribe.Cache;
+
+///
+/// Process-wide string intern pool for strings that repeat across an incremental
+/// compilation — diagnostic IDs, fully-qualified type names, file paths, attribute
+/// metadata names.
+///
+///
+///
+/// The primary benefit is reducing on
+/// cache-key comparison paths. An interned string compares reference-first, so the
+/// hot path collapses from O(length) to O(1) in the common case.
+///
+///
+/// This pool is separate from the BCL to avoid
+/// polluting the CLR's global intern table with per-analyzer strings.
+///
+///
+/// Scope: pool strings that are (a) small, (b) high-repetition,
+/// (c) drawn from a bounded vocabulary. Do not intern user message text,
+/// user-authored member names, or anything unbounded.
+///
+///
+public static class InternPool
+{
+ private static readonly ConcurrentDictionary Pool = new();
+
+ ///
+ /// Return the canonical interned instance for . Subsequent
+ /// calls with an equal string return the same reference.
+ ///
+ public static string Intern(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return Pool.GetOrAdd(value, value);
+ }
+
+ /// Current number of distinct strings in the pool. Primarily for diagnostics/tests.
+ public static int Count => Pool.Count;
+}
diff --git a/Scribe/Cache/LocationInfo.cs b/Scribe/Cache/LocationInfo.cs
new file mode 100644
index 0000000..c9b2ff4
--- /dev/null
+++ b/Scribe/Cache/LocationInfo.cs
@@ -0,0 +1,53 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Scribe.Cache;
+
+///
+/// A cache-safe stand-in for . Captures file path and spans
+/// as primitive values so the enclosing value can flow through the Roslyn incremental
+/// cache without pinning a / .
+///
+///
+///
+/// Roslyn's holds a reference to its ,
+/// which in turn is tied to a . Storing a
+/// in any cached pipeline value pins the entire compilation in memory and invalidates
+/// the cache on every compilation change.
+///
+///
+/// Convert at the boundary: use when entering the pipeline
+/// and when materialising a diagnostic for reporting.
+///
+///
+public readonly record struct LocationInfo(
+ string FilePath,
+ TextSpan TextSpan,
+ LinePositionSpan LineSpan)
+{
+ ///
+ /// Convert a Roslyn into a cache-safe .
+ /// Returns for null, non-source, or tree-less locations.
+ ///
+ public static LocationInfo? From(Location? location)
+ {
+ if (location is null || !location.IsInSource)
+ {
+ return null;
+ }
+
+ var tree = location.SourceTree;
+ if (tree is null)
+ {
+ return null;
+ }
+
+ return new LocationInfo(
+ InternPool.Intern(tree.FilePath),
+ location.SourceSpan,
+ location.GetLineSpan().Span);
+ }
+
+ /// Materialise back into a Roslyn for reporting.
+ public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan);
+}
diff --git a/Scribe/Cache/WellKnownTypes.cs b/Scribe/Cache/WellKnownTypes.cs
new file mode 100644
index 0000000..e2911c5
--- /dev/null
+++ b/Scribe/Cache/WellKnownTypes.cs
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace Scribe.Cache;
+
+///
+/// A small per- cache of s
+/// resolved by metadata name, so each symbol is looked up at most once per compilation.
+///
+///
+///
+/// Not cache-safe. This type holds
+/// references, which are tied to the specific they were
+/// resolved from. Construct it fresh at the edge of an incremental pipeline
+/// stage (typically inside a CompilationProvider.Select callback) and discard
+/// it before any cached value crosses the stage boundary.
+///
+///
+/// The purpose is purely to avoid repeating
+/// — an O(n) scan over
+/// referenced assemblies — for the same name within a single pipeline stage.
+///
+///
+public sealed class WellKnownTypes
+{
+ private readonly Dictionary _cache;
+
+ private WellKnownTypes(Dictionary cache) => _cache = cache;
+
+ ///
+ /// Resolve all listed metadata names against and build
+ /// a fresh cache.
+ ///
+ public static WellKnownTypes From(Compilation compilation, params string[] metadataNames)
+ {
+ if (compilation is null)
+ {
+ throw new System.ArgumentNullException(nameof(compilation));
+ }
+
+ var cache = new Dictionary(
+ metadataNames?.Length ?? 0,
+ System.StringComparer.Ordinal);
+
+ if (metadataNames is not null)
+ {
+ foreach (var name in metadataNames)
+ {
+ if (!string.IsNullOrEmpty(name) && !cache.ContainsKey(name))
+ {
+ cache[name] = compilation.GetTypeByMetadataName(name);
+ }
+ }
+ }
+
+ return new WellKnownTypes(cache);
+ }
+
+ ///
+ /// Resolve a metadata name, caching the result. Unlike ,
+ /// this is used when the set of required types is not known up front. Requires the caller
+ /// to hold a live .
+ ///
+ public INamedTypeSymbol? Resolve(Compilation compilation, string metadataName)
+ {
+ if (_cache.TryGetValue(metadataName, out var cached))
+ {
+ return cached;
+ }
+
+ var resolved = compilation.GetTypeByMetadataName(metadataName);
+ _cache[metadataName] = resolved;
+ return resolved;
+ }
+
+ /// Look up a pre-resolved metadata name. Returns if not present or not found.
+ public INamedTypeSymbol? Get(string metadataName) =>
+ _cache.TryGetValue(metadataName, out var symbol) ? symbol : null;
+
+ /// Look up a pre-resolved metadata name, returning if not present.
+ public bool TryGet(string metadataName, out INamedTypeSymbol? symbol) =>
+ _cache.TryGetValue(metadataName, out symbol);
+}
diff --git a/Scribe/Scribe.csproj b/Scribe/Scribe.csproj
index affb488..517f1f2 100644
--- a/Scribe/Scribe.csproj
+++ b/Scribe/Scribe.csproj
@@ -23,6 +23,7 @@
+
@@ -42,4 +43,12 @@
+
+
+
+
+
diff --git a/Scribe/Shapes/DiagnosticSpec.cs b/Scribe/Shapes/DiagnosticSpec.cs
new file mode 100644
index 0000000..5b8a363
--- /dev/null
+++ b/Scribe/Shapes/DiagnosticSpec.cs
@@ -0,0 +1,21 @@
+using Microsoft.CodeAnalysis;
+
+namespace Scribe.Shapes;
+
+///
+/// Selective override for a check's default diagnostic
+/// descriptor. Any field left keeps the primitive's default.
+///
+/// Diagnostic ID (defaults to SCRIBE-prefixed per primitive).
+/// One-line diagnostic title.
+/// Message format — {0} etc. will be substituted with the primitive's arguments.
+/// Severity — keeps the primitive default.
+/// Squiggle anchor.
+/// Override for the auto-fix hint.
+public readonly record struct DiagnosticSpec(
+ string? Id = null,
+ string? Title = null,
+ string? Message = null,
+ DiagnosticSeverity? Severity = null,
+ SquiggleAt? Target = null,
+ FixSpec? Fix = null);
diff --git a/Scribe/Shapes/FixSpec.cs b/Scribe/Shapes/FixSpec.cs
new file mode 100644
index 0000000..2c8e20e
--- /dev/null
+++ b/Scribe/Shapes/FixSpec.cs
@@ -0,0 +1,49 @@
+namespace Scribe.Shapes;
+
+///
+/// A declarative hint — picked up by Phase-4 code-fix generation — describing
+/// how a violation of a check should be auto-repaired.
+///
+public readonly record struct FixSpec(FixKind Kind, string? EquivalenceKey = null);
+
+///
+/// Catalog of built-in fix shapes recognised by Scribe.Fixes.
+///
+public enum FixKind
+{
+ /// No automatic fix — the diagnostic is informational or requires user judgement.
+ None,
+
+ /// Add the partial modifier to the type declaration.
+ AddPartialModifier,
+
+ /// Add the sealed modifier to the type declaration.
+ AddSealedModifier,
+
+ /// Add an interface to the base list.
+ AddInterfaceToBaseList,
+
+ /// Remove a specific entry from the base list.
+ RemoveFromBaseList,
+
+ /// Add an attribute to the declaration.
+ AddAttribute,
+
+ /// Rename the type to match a specified pattern.
+ RenameTo,
+
+ /// Generate a stub method matching a specified signature.
+ AddStubMethod,
+
+ /// Add the abstract modifier to the type declaration.
+ AddAbstractModifier,
+
+ /// Remove the abstract modifier from the type declaration.
+ RemoveAbstractModifier,
+
+ /// Add the static modifier to the type declaration.
+ AddStaticModifier,
+
+ /// Add a base class to the type declaration (fix property baseClass).
+ AddBaseClass,
+}
diff --git a/Scribe/Shapes/Relation.cs b/Scribe/Shapes/Relation.cs
new file mode 100644
index 0000000..d8775f8
--- /dev/null
+++ b/Scribe/Shapes/Relation.cs
@@ -0,0 +1,338 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Scribe.Cache;
+
+namespace Scribe.Shapes;
+
+///
+/// Join two shape streams on a key. Produces a matched-pair stream plus an
+/// opt-in diagnostic stream for orphaned sides. Orphans are silent by default —
+/// the pair is a query, not a requirement — so the author must declare
+/// what missing means by calling
+/// or .
+///
+public static class Relation
+{
+ ///
+ /// Join and by equal keys.
+ /// The returned builder lazily wires matched-pair and diagnostic providers
+ /// when their properties are first accessed.
+ ///
+ public static PairBuilder Pair(
+ IncrementalValuesProvider> left,
+ IncrementalValuesProvider> right,
+ Func leftKey,
+ Func rightKey)
+ where TLeft : IEquatable
+ where TRight : IEquatable
+ => new(left, right, leftKey, rightKey);
+}
+
+///
+/// Configuration surface for a join.
+/// Each orphan policy returns this so the declaration reads left-to-right.
+///
+public sealed class PairBuilder
+ where TLeft : IEquatable
+ where TRight : IEquatable
+{
+ private readonly IncrementalValuesProvider> _left;
+ private readonly IncrementalValuesProvider> _right;
+ private readonly Func _leftKey;
+ private readonly Func _rightKey;
+
+ private OrphanPolicy? _leftHasRight;
+ private OrphanPolicy? _rightUnused;
+
+ internal PairBuilder(
+ IncrementalValuesProvider> left,
+ IncrementalValuesProvider> right,
+ Func leftKey,
+ Func rightKey)
+ {
+ _left = left;
+ _right = right;
+ _leftKey = leftKey;
+ _rightKey = rightKey;
+ }
+
+ ///
+ /// Emit a diagnostic for every left-side item whose key matches no right-side
+ /// item. The message receives {0} = left.Fqn and {1} = missing key.
+ ///
+ public PairBuilder RequireLeftHasRight(
+ string id,
+ string title,
+ string messageFormat,
+ DiagnosticSeverity severity = DiagnosticSeverity.Error)
+ {
+ _leftHasRight = new OrphanPolicy(id, title, messageFormat, severity);
+ return this;
+ }
+
+ ///
+ /// Emit a diagnostic for every right-side item whose key is not referenced by
+ /// any left-side item. The message receives {0} = right.Fqn.
+ ///
+ public PairBuilder WarnOnRightUnused(
+ string id,
+ string title,
+ string messageFormat,
+ DiagnosticSeverity severity = DiagnosticSeverity.Warning)
+ {
+ _rightUnused = new OrphanPolicy(id, title, messageFormat, severity);
+ return this;
+ }
+
+ ///
+ /// Stream of successful joins. Each left item is paired with the first
+ /// right-side item sharing its key; duplicate right-side keys drop to first-
+ /// wins in v1 (a RequireUniqueRightKey overload may be added later).
+ ///
+ public IncrementalValuesProvider> Matched
+ {
+ get
+ {
+ var leftKey = _leftKey;
+ var rightKey = _rightKey;
+
+ var rightByKey = _right
+ .Collect()
+ .Select((arr, _) => BuildRightIndex(arr, rightKey));
+
+ return _left
+ .Combine(rightByKey)
+ .Select((tuple, _) => TryPair(tuple.Left, tuple.Right, leftKey))
+ .Where(p => p.HasValue)
+ .Select((p, _) => p!.Value);
+ }
+ }
+
+ ///
+ /// Stream of orphan diagnostics. Empty unless
+ /// or was
+ /// configured. Pair with when reporting, or call
+ /// to wire everything up in one line.
+ ///
+ public IncrementalValuesProvider Diagnostics
+ {
+ get
+ {
+ var leftKey = _leftKey;
+ var rightKey = _rightKey;
+ var leftPolicy = _leftHasRight;
+ var rightPolicy = _rightUnused;
+
+ var rightByKey = _right
+ .Collect()
+ .Select((arr, _) => BuildRightIndex(arr, rightKey));
+
+ var leftOrphans = _left
+ .Combine(rightByKey)
+ .Select((tuple, _) => FindLeftOrphan(tuple.Left, tuple.Right, leftKey, leftPolicy))
+ .Where(d => d.HasValue)
+ .Select((d, _) => d!.Value);
+
+ var leftKeys = _left
+ .Collect()
+ .Select((arr, _) => BuildLeftKeySet(arr, leftKey));
+
+ var rightOrphans = _right
+ .Combine(leftKeys)
+ .Select((tuple, _) => FindRightOrphan(tuple.Left, tuple.Right, rightKey, rightPolicy))
+ .Where(d => d.HasValue)
+ .Select((d, _) => d!.Value);
+
+ return leftOrphans.Collect()
+ .Combine(rightOrphans.Collect())
+ .SelectMany((pair, _) => Concat(pair.Left, pair.Right));
+ }
+ }
+
+ ///
+ /// One descriptor per configured orphan policy, keyed by diagnostic id.
+ /// Consumer-side analyzers/generators must include these in their
+ /// SupportedDiagnostics when reporting.
+ ///
+ public ImmutableArray Descriptors
+ {
+ get
+ {
+ var builder = ImmutableArray.CreateBuilder(2);
+ if (_leftHasRight is { } l)
+ {
+ builder.Add(BuildDescriptor(l));
+ }
+
+ if (_rightUnused is { } r)
+ {
+ builder.Add(BuildDescriptor(r));
+ }
+
+ return builder.ToImmutable();
+ }
+ }
+
+ ///
+ /// One-liner: materialize every orphan with the
+ /// matching descriptor and report it. Call from
+ /// IncrementalGenerator.Initialize.
+ ///
+ public void RegisterDiagnostics(IncrementalGeneratorInitializationContext context)
+ {
+ var descriptors = Descriptors;
+ if (descriptors.IsEmpty)
+ {
+ return;
+ }
+
+ context.RegisterSourceOutput(Diagnostics, (spc, info) =>
+ {
+ foreach (var descriptor in descriptors)
+ {
+ if (string.Equals(descriptor.Id, info.Id, StringComparison.Ordinal))
+ {
+ spc.ReportDiagnostic(info.Materialize(descriptor));
+ return;
+ }
+ }
+ });
+ }
+
+ private static DiagnosticDescriptor BuildDescriptor(OrphanPolicy policy) =>
+ new(
+ id: policy.Id,
+ title: policy.Title,
+ messageFormat: policy.MessageFormat,
+ category: "Scribe.Relation",
+ defaultSeverity: policy.Severity,
+ isEnabledByDefault: true);
+
+ private static IEnumerable Concat(
+ ImmutableArray a,
+ ImmutableArray b)
+ {
+ foreach (var d in a)
+ {
+ yield return d;
+ }
+
+ foreach (var d in b)
+ {
+ yield return d;
+ }
+ }
+
+ private static ImmutableDictionary> BuildRightIndex(
+ ImmutableArray> rights,
+ Func keyOf)
+ {
+ var builder = ImmutableDictionary.CreateBuilder>(StringComparer.Ordinal);
+ foreach (var right in rights)
+ {
+ var key = keyOf(right.Model);
+ if (key is null)
+ {
+ continue;
+ }
+
+ if (!builder.ContainsKey(key))
+ {
+ builder.Add(key, right);
+ }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ private static ImmutableHashSet BuildLeftKeySet(
+ ImmutableArray> lefts,
+ Func keyOf)
+ {
+ var builder = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal);
+ foreach (var left in lefts)
+ {
+ var key = keyOf(left.Model);
+ if (key is null)
+ {
+ continue;
+ }
+
+ builder.Add(key);
+ }
+
+ return builder.ToImmutable();
+ }
+
+ private static ShapedPair? TryPair(
+ ShapedSymbol left,
+ ImmutableDictionary> rightByKey,
+ Func leftKey)
+ {
+ var key = leftKey(left.Model);
+ if (key is null)
+ {
+ return null;
+ }
+
+ return rightByKey.TryGetValue(key, out var right)
+ ? new ShapedPair(left, right)
+ : null;
+ }
+
+ private static DiagnosticInfo? FindLeftOrphan(
+ ShapedSymbol left,
+ ImmutableDictionary> rightByKey,
+ Func leftKey,
+ OrphanPolicy? policy)
+ {
+ if (policy is null)
+ {
+ return null;
+ }
+
+ var key = leftKey(left.Model);
+ if (key is null || rightByKey.ContainsKey(key))
+ {
+ return null;
+ }
+
+ return new DiagnosticInfo(
+ Id: policy.Value.Id,
+ Severity: policy.Value.Severity,
+ MessageArgs: EquatableArray.Create(left.Fqn, key),
+ Location: left.Location);
+ }
+
+ private static DiagnosticInfo? FindRightOrphan(
+ ShapedSymbol right,
+ ImmutableHashSet leftKeys,
+ Func rightKey,
+ OrphanPolicy? policy)
+ {
+ if (policy is null)
+ {
+ return null;
+ }
+
+ var key = rightKey(right.Model);
+ if (key is null || leftKeys.Contains(key))
+ {
+ return null;
+ }
+
+ return new DiagnosticInfo(
+ Id: policy.Value.Id,
+ Severity: policy.Value.Severity,
+ MessageArgs: EquatableArray.Create(right.Fqn),
+ Location: right.Location);
+ }
+
+ private readonly record struct OrphanPolicy(
+ string Id,
+ string Title,
+ string MessageFormat,
+ DiagnosticSeverity Severity);
+}
diff --git a/Scribe/Shapes/SeverityVariants.cs b/Scribe/Shapes/SeverityVariants.cs
new file mode 100644
index 0000000..1c39aa6
--- /dev/null
+++ b/Scribe/Shapes/SeverityVariants.cs
@@ -0,0 +1,28 @@
+using Microsoft.CodeAnalysis;
+
+namespace Scribe.Shapes;
+
+///
+/// Helpers used by the generated Should* / Could* /
+/// ShouldNot* variants of each Must* primitive. Applies a
+/// default severity only when the caller has not explicitly chosen one.
+///
+internal static class SeverityVariants
+{
+ ///
+ /// Return a whose
+ /// is the one the caller provided, or — if the caller left it unset —
+ /// .
+ ///
+ public static DiagnosticSpec WithSeverity(DiagnosticSpec? spec, DiagnosticSeverity defaultSeverity)
+ {
+ if (spec is null)
+ {
+ return new DiagnosticSpec(Severity: defaultSeverity);
+ }
+
+ return spec.Value.Severity is null
+ ? spec.Value with { Severity = defaultSeverity }
+ : spec.Value;
+ }
+}
diff --git a/Scribe/Shapes/Shape.cs b/Scribe/Shapes/Shape.cs
new file mode 100644
index 0000000..bce4d23
--- /dev/null
+++ b/Scribe/Shapes/Shape.cs
@@ -0,0 +1,38 @@
+namespace Scribe.Shapes;
+
+///
+/// Entry point for the Shape DSL. Pick the type-kind you want to match, then fluently
+/// layer MustBeX constraints, then
+/// into a cache-safe model.
+///
+///
+///
+/// var shape = Shape.Class()
+/// .MustHaveAttribute<ThingAttribute>()
+/// .MustBePartial()
+/// .MustImplement<IThing>()
+/// .Project(in ctx => new ThingModel(
+/// Fqn: ctx.Fqn,
+/// Flavor: ctx.Attribute.Ctor<string>(0) ?? "default"));
+///
+///
+public static class Shape
+{
+ /// Match class declarations.
+ public static ShapeBuilder Class() => new(TypeKindFilter.Class);
+
+ /// Match record declarations (class form).
+ public static ShapeBuilder Record() => new(TypeKindFilter.Record);
+
+ /// Match record struct declarations.
+ public static ShapeBuilder RecordStruct() => new(TypeKindFilter.RecordStruct);
+
+ /// Match struct declarations.
+ public static ShapeBuilder Struct() => new(TypeKindFilter.Struct);
+
+ /// Match interface declarations.
+ public static ShapeBuilder Interface() => new(TypeKindFilter.Interface);
+
+ /// Match any type declaration regardless of kind.
+ public static ShapeBuilder AnyType() => new(TypeKindFilter.Any);
+}
diff --git a/Scribe/Shapes/ShapeBuilder.cs b/Scribe/Shapes/ShapeBuilder.cs
new file mode 100644
index 0000000..f388bc4
--- /dev/null
+++ b/Scribe/Shapes/ShapeBuilder.cs
@@ -0,0 +1,497 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Text.RegularExpressions;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Scribe.Cache;
+
+namespace Scribe.Shapes;
+
+///
+/// Fluent builder that accumulates a conjunction of MustBeX checks on a single
+/// type-shape subject and projects the match into a cache-safe TModel.
+///
+///
+/// Obtain via the factories on .
+/// Each primitive is a declarative constraint: the check carries a default
+/// diagnostic ID, title, message, severity, squiggle anchor, and fix hint; callers
+/// can selectively override any of these via a .
+///
+public sealed partial class ShapeBuilder
+{
+ private readonly TypeKindFilter _kind;
+ private readonly List _checks = new();
+ private string? _primaryAttributeMetadataName;
+
+ internal ShapeBuilder(TypeKindFilter kind) => _kind = kind;
+
+ internal TypeKindFilter Kind => _kind;
+ internal IReadOnlyList Checks => _checks;
+ internal string? PrimaryAttributeMetadataName => _primaryAttributeMetadataName;
+
+ ///
+ /// Seal the builder and project matches into a cache-safe model.
+ /// The projection runs inside the incremental pipeline's transform stage, with
+ /// access to the matched symbol, semantic model, and — if declared — the
+ /// driving attribute reader.
+ ///
+ /// Must be for cache correctness.
+ public Shape Project(ProjectionDelegate project)
+ where TModel : IEquatable
+ {
+ if (project is null)
+ {
+ throw new ArgumentNullException(nameof(project));
+ }
+
+ return new Shape(
+ kind: _kind,
+ checks: _checks.ToArray(),
+ primaryAttributeMetadataName: _primaryAttributeMetadataName,
+ project: project);
+ }
+
+ internal bool MatchesSyntaxNode(SyntaxNode node) =>
+ _kind switch
+ {
+ TypeKindFilter.Class =>
+ node is ClassDeclarationSyntax c && !c.Modifiers.Any(SyntaxKind.RecordKeyword),
+ TypeKindFilter.Record =>
+ node is RecordDeclarationSyntax r && r.IsKind(SyntaxKind.RecordDeclaration),
+ TypeKindFilter.RecordStruct =>
+ node is RecordDeclarationSyntax rs && rs.IsKind(SyntaxKind.RecordStructDeclaration),
+ TypeKindFilter.Struct =>
+ node is StructDeclarationSyntax s && !s.Modifiers.Any(SyntaxKind.RecordKeyword),
+ TypeKindFilter.Interface =>
+ node is InterfaceDeclarationSyntax,
+ TypeKindFilter.Any =>
+ node is TypeDeclarationSyntax,
+ _ => false,
+ };
+
+ internal bool MatchesSymbolKind(INamedTypeSymbol symbol) =>
+ _kind switch
+ {
+ TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord,
+ TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord,
+ TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord,
+ TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord,
+ TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface,
+ TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface,
+ _ => false,
+ };
+
+ private void AddCheck(
+ string defaultId,
+ string defaultTitle,
+ string defaultMessage,
+ DiagnosticSeverity defaultSeverity,
+ SquiggleAt defaultSquiggle,
+ FixKind defaultFix,
+ Func predicate,
+ Func> messageArgs,
+ DiagnosticSpec? spec,
+ Func>? fixProperties = null)
+ {
+ _checks.Add(new ShapeCheck(
+ Id: InternPool.Intern(spec?.Id ?? defaultId),
+ Title: spec?.Title ?? defaultTitle,
+ MessageFormat: spec?.Message ?? defaultMessage,
+ Severity: spec?.Severity ?? defaultSeverity,
+ SquiggleAt: spec?.Target ?? defaultSquiggle,
+ FixKind: spec?.Fix?.Kind ?? defaultFix,
+ Predicate: predicate,
+ MessageArgs: messageArgs,
+ FixProperties: fixProperties));
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Primitives (Phase 3)
+ // ───────────────────────────────────────────────────────────────
+
+ /// Require the type to be declared partial.
+ public ShapeBuilder MustBePartial(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE001",
+ defaultTitle: "Type must be partial",
+ defaultMessage: "Type '{0}' must be declared 'partial'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.AddPartialModifier,
+ predicate: static (sym, _, ct) => IsPartial(sym, ct),
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Require the type to be declared sealed.
+ public ShapeBuilder MustBeSealed(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE002",
+ defaultTitle: "Type must be sealed",
+ defaultMessage: "Type '{0}' must be declared 'sealed'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.AddSealedModifier,
+ predicate: static (sym, _, _) => sym.IsSealed || sym.IsValueType || sym.IsStatic,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ ///
+ /// Require the type to implement . For non-generic interfaces
+ /// only in v1 — use the overload
+ /// with a metadata name for generic interfaces.
+ ///
+ public ShapeBuilder MustImplement(DiagnosticSpec? spec = null)
+ where T : class
+ {
+ var fqn = typeof(T).FullName!;
+ return MustImplement(fqn, spec);
+ }
+
+ /// Require the type to implement the interface named by .
+ public ShapeBuilder MustImplement(string metadataName, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ AddCheck(
+ defaultId: "SCRIBE003",
+ defaultTitle: "Type must implement required interface",
+ defaultMessage: "Type '{0}' must implement '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.BaseList,
+ defaultFix: FixKind.AddInterfaceToBaseList,
+ predicate: (sym, comp, _) => ImplementsByMetadataName(sym, comp, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("interface", interned));
+ return this;
+ }
+
+ ///
+ /// Require the type to carry attribute . The first
+ /// MustHaveAttribute declared on the shape is promoted to the driving
+ /// attribute and routes collection through
+ /// .
+ ///
+ public ShapeBuilder MustHaveAttribute(DiagnosticSpec? spec = null)
+ where T : System.Attribute
+ => MustHaveAttribute(typeof(T).FullName!, spec);
+
+ ///
+ /// Require the type to carry the attribute named by .
+ ///
+ public ShapeBuilder MustHaveAttribute(string metadataName, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ _primaryAttributeMetadataName ??= interned;
+
+ AddCheck(
+ defaultId: "SCRIBE004",
+ defaultTitle: "Type must have required attribute",
+ defaultMessage: "Type '{0}' must be annotated with '[{1}]'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.AddAttribute,
+ predicate: (sym, _, _) => HasAttribute(sym, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("attribute", interned));
+ return this;
+ }
+
+ ///
+ /// Require the type's to match
+ /// as a regular expression. No auto-fix is offered by default — a regex pattern
+ /// does not uniquely identify a target name; override via
+ /// with a concrete to supply one.
+ ///
+ public ShapeBuilder MustBeNamed(string pattern, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(pattern))
+ {
+ throw new ArgumentException("Pattern must not be empty.", nameof(pattern));
+ }
+
+ var regex = new Regex(pattern, RegexOptions.CultureInvariant);
+ AddCheck(
+ defaultId: "SCRIBE005",
+ defaultTitle: "Type name must match required pattern",
+ defaultMessage: "Type '{0}' name does not match pattern '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.None,
+ predicate: (sym, _, _) => regex.IsMatch(sym.Name),
+ messageArgs: sym => EquatableArray.Create(sym.Name, pattern),
+ spec: spec);
+ return this;
+ }
+
+ /// Require the type to be declared abstract.
+ public ShapeBuilder MustBeAbstract(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE006",
+ defaultTitle: "Type must be abstract",
+ defaultMessage: "Type '{0}' must be declared 'abstract'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.AddAbstractModifier,
+ predicate: static (sym, _, _) => sym.IsAbstract,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Require the type to be declared static.
+ public ShapeBuilder MustBeStatic(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE007",
+ defaultTitle: "Type must be static",
+ defaultMessage: "Type '{0}' must be declared 'static'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.AddStaticModifier,
+ predicate: static (sym, _, _) => sym.IsStatic,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ ///
+ /// Require the type to extend the base class .
+ /// Interfaces use instead.
+ ///
+ public ShapeBuilder MustExtend(DiagnosticSpec? spec = null)
+ where T : class
+ => MustExtend(typeof(T).FullName!, spec);
+
+ ///
+ /// Require the type to extend the base class named by .
+ ///
+ public ShapeBuilder MustExtend(string metadataName, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ AddCheck(
+ defaultId: "SCRIBE008",
+ defaultTitle: "Type must extend required base class",
+ defaultMessage: "Type '{0}' must extend base class '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.BaseList,
+ defaultFix: FixKind.AddBaseClass,
+ predicate: (sym, compilation, _) => ExtendsByMetadataName(sym, compilation, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("baseClass", interned));
+ return this;
+ }
+
+ ///
+ /// Require the type's containing namespace to match
+ /// as a regular expression. No auto-fix is offered — moving a file is outside
+ /// Scribe's automation surface.
+ ///
+ public ShapeBuilder MustBeInNamespace(string pattern, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(pattern))
+ {
+ throw new ArgumentException("Pattern must not be empty.", nameof(pattern));
+ }
+
+ var regex = new Regex(pattern, RegexOptions.CultureInvariant);
+ AddCheck(
+ defaultId: "SCRIBE009",
+ defaultTitle: "Type's namespace must match required pattern",
+ defaultMessage: "Type '{0}' is in namespace '{1}' which does not match pattern '{2}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ContainingNamespace,
+ defaultFix: FixKind.None,
+ predicate: (sym, _, _) => regex.IsMatch(sym.ContainingNamespace?.ToDisplayString() ?? string.Empty),
+ messageArgs: sym => EquatableArray.Create(
+ sym.Name,
+ sym.ContainingNamespace?.ToDisplayString() ?? string.Empty,
+ pattern),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the abstract modifier on the type.
+ public ShapeBuilder MustNotBeAbstract(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE010",
+ defaultTitle: "Type must not be abstract",
+ defaultMessage: "Type '{0}' must not be declared 'abstract'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.RemoveAbstractModifier,
+ predicate: static (sym, _, _) => !sym.IsAbstract,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid generic type parameters on the type.
+ public ShapeBuilder MustNotBeGeneric(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE011",
+ defaultTitle: "Type must not be generic",
+ defaultMessage: "Type '{0}' must not declare generic type parameters",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => !sym.IsGenericType,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid implementation of interface .
+ public ShapeBuilder MustNotImplement(DiagnosticSpec? spec = null)
+ where T : class
+ => MustNotImplement(typeof(T).FullName!, spec);
+
+ ///
+ /// Forbid implementation of the interface named by .
+ ///
+ public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec = null)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ AddCheck(
+ defaultId: "SCRIBE012",
+ defaultTitle: "Type must not implement forbidden interface",
+ defaultMessage: "Type '{0}' must not implement '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.BaseList,
+ defaultFix: FixKind.RemoveFromBaseList,
+ predicate: (sym, compilation, _) => !ImplementsByMetadataName(sym, compilation, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("interface", interned));
+ return this;
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Predicate helpers (static, closure-free)
+ // ───────────────────────────────────────────────────────────────
+
+ private static bool IsPartial(INamedTypeSymbol symbol, CancellationToken ct)
+ {
+ var refs = symbol.DeclaringSyntaxReferences;
+ foreach (var reference in refs)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (reference.GetSyntax(ct) is TypeDeclarationSyntax tds
+ && tds.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool ImplementsByMetadataName(
+ INamedTypeSymbol symbol, Compilation compilation, string metadataName)
+ {
+ var target = compilation.GetTypeByMetadataName(metadataName);
+ if (target is null)
+ {
+ // If the interface is not visible in this compilation, cannot assert — treat as satisfied.
+ return true;
+ }
+
+ foreach (var iface in symbol.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, target))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool ExtendsByMetadataName(
+ INamedTypeSymbol symbol, Compilation compilation, string metadataName)
+ {
+ var target = compilation.GetTypeByMetadataName(metadataName);
+ if (target is null)
+ {
+ // Base class not visible in this compilation — cannot assert, treat as satisfied.
+ return true;
+ }
+
+ var current = symbol.BaseType;
+ while (current is not null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, target))
+ {
+ return true;
+ }
+
+ current = current.BaseType;
+ }
+
+ return false;
+ }
+
+ private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName)
+ {
+ foreach (var attribute in symbol.GetAttributes())
+ {
+ var fqn = attribute.AttributeClass?.ToDisplayString();
+ if (fqn is null)
+ {
+ continue;
+ }
+
+ if (string.Equals(fqn, metadataName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Strip generic args and/or trailing "Attribute" for a forgiving match.
+ var bracket = fqn.IndexOf('<');
+ var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn;
+ if (string.Equals(bare, metadataName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Scribe/Shapes/ShapeCheck.cs b/Scribe/Shapes/ShapeCheck.cs
new file mode 100644
index 0000000..5959baa
--- /dev/null
+++ b/Scribe/Shapes/ShapeCheck.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Scribe.Cache;
+
+namespace Scribe.Shapes;
+
+///
+/// Internal, opaque representation of a single declarative check registered on a
+/// . A check pairs a positive
+/// ( = shape satisfied) with the metadata needed to emit a
+/// diagnostic when violated and the data needed to apply an automatic code fix.
+///
+internal sealed record ShapeCheck(
+ string Id,
+ string Title,
+ string MessageFormat,
+ DiagnosticSeverity Severity,
+ SquiggleAt SquiggleAt,
+ FixKind FixKind,
+ Func Predicate,
+ Func> MessageArgs,
+ Func>? FixProperties = null);
diff --git a/Scribe/Shapes/ShapeProjectionContext.cs b/Scribe/Shapes/ShapeProjectionContext.cs
new file mode 100644
index 0000000..e5e6d97
--- /dev/null
+++ b/Scribe/Shapes/ShapeProjectionContext.cs
@@ -0,0 +1,60 @@
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Scribe.Attributes;
+
+namespace Scribe.Shapes;
+
+///
+/// Stack-only context passed to the user projection callback of
+/// . Exposes the matched symbol and the
+/// attribute reader (if declared
+/// the driving attribute).
+///
+///
+/// This is a ref struct: it pins and must not
+/// escape the projection. Use it only to extract the cache-safe values that will
+/// live in the resulting TModel.
+///
+public readonly ref struct ShapeProjectionContext
+{
+ private readonly INamedTypeSymbol _symbol;
+ private readonly AttributeReader _attribute;
+ private readonly SemanticModel _semanticModel;
+ private readonly Compilation _compilation;
+ private readonly CancellationToken _cancellationToken;
+
+ internal ShapeProjectionContext(
+ INamedTypeSymbol symbol,
+ AttributeReader attribute,
+ SemanticModel semanticModel,
+ Compilation compilation,
+ CancellationToken cancellationToken)
+ {
+ _symbol = symbol;
+ _attribute = attribute;
+ _semanticModel = semanticModel;
+ _compilation = compilation;
+ _cancellationToken = cancellationToken;
+ }
+
+ /// The matched type symbol.
+ public INamedTypeSymbol Symbol => _symbol;
+
+ /// Reader over the driving attribute, if was declared.
+ public AttributeReader Attribute => _attribute;
+
+ /// Semantic model of the declaration's syntax tree.
+ public SemanticModel SemanticModel => _semanticModel;
+
+ /// Current compilation.
+ public Compilation Compilation => _compilation;
+
+ /// Cancellation token flowed from the incremental pipeline.
+ public CancellationToken CancellationToken => _cancellationToken;
+
+ /// Fully-qualified display name of the matched type.
+ public string Fqn => _symbol.ToDisplayString();
+}
+
+/// Projection delegate for .
+public delegate TModel ProjectionDelegate(in ShapeProjectionContext ctx);
diff --git a/Scribe/Shapes/Shape_T.ToAnalyzer.cs b/Scribe/Shapes/Shape_T.ToAnalyzer.cs
new file mode 100644
index 0000000..496bfa9
--- /dev/null
+++ b/Scribe/Shapes/Shape_T.ToAnalyzer.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Scribe.Shapes;
+
+///
+/// Triple-lowering: project the same declarative shape into a
+/// . Each registered check becomes a
+/// ; on each
+/// the analyser runs the same predicates the generator pipeline runs and emits
+/// s at each check's .
+///
+public sealed partial class Shape
+{
+ private DiagnosticAnalyzer? _cachedAnalyzer;
+ private ImmutableArray _cachedDescriptors;
+ private readonly ConcurrentDictionary _descriptorsById = new();
+
+ ///
+ /// Return a that reports this shape's checks
+ /// against every matching type in a compilation. Package the returned instance
+ /// inside a concrete [DiagnosticAnalyzer]-attributed wrapper class for
+ /// deployment as an analyzer.
+ ///
+ public DiagnosticAnalyzer ToAnalyzer() =>
+ _cachedAnalyzer ??= new ShapeDiagnosticAnalyzer(this);
+
+ internal ImmutableArray SupportedDescriptors
+ {
+ get
+ {
+ if (_cachedDescriptors.IsDefault)
+ {
+ var builder = ImmutableArray.CreateBuilder(_checks.Length);
+ foreach (var check in _checks)
+ {
+ builder.Add(DescriptorFor(check));
+ }
+
+ _cachedDescriptors = builder.ToImmutable();
+ }
+
+ return _cachedDescriptors;
+ }
+ }
+
+ internal DiagnosticDescriptor DescriptorFor(ShapeCheck check)
+ {
+ return _descriptorsById.GetOrAdd(check.Id, _ => new DiagnosticDescriptor(
+ id: check.Id,
+ title: check.Title,
+ messageFormat: check.MessageFormat,
+ category: "Scribe.Shape",
+ defaultSeverity: check.Severity,
+ isEnabledByDefault: true));
+ }
+
+ internal TypeKindFilter KindFilter => _kind;
+ internal string? PrimaryAttributeName => _primaryAttributeMetadataName;
+ internal ShapeCheck[] CheckList => _checks;
+
+ [DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
+ private sealed class ShapeDiagnosticAnalyzer : DiagnosticAnalyzer
+ {
+ private readonly Shape _shape;
+
+ public ShapeDiagnosticAnalyzer(Shape shape) => _shape = shape;
+
+ public override ImmutableArray SupportedDiagnostics
+ => _shape.SupportedDescriptors;
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
+ }
+
+ private void AnalyzeNamedType(SymbolAnalysisContext ctx)
+ {
+ if (ctx.Symbol is not INamedTypeSymbol type)
+ {
+ return;
+ }
+
+ if (!MatchesKind(_shape.KindFilter, type))
+ {
+ return;
+ }
+
+ if (_shape.PrimaryAttributeName is { } attrName
+ && !HasAttribute(type, attrName))
+ {
+ // Shape drives off an attribute the type doesn't carry — silently skip.
+ return;
+ }
+
+ foreach (var check in _shape.CheckList)
+ {
+ ctx.CancellationToken.ThrowIfCancellationRequested();
+ if (check.Predicate(type, ctx.Compilation, ctx.CancellationToken))
+ {
+ continue;
+ }
+
+ var descriptor = _shape.DescriptorFor(check);
+ var location = SquiggleLocator.Resolve(type, check.SquiggleAt, ctx.CancellationToken);
+ var properties = BuildProperties(check, type);
+ var args = check.MessageArgs(type);
+ var objArgs = new object?[args.Count];
+ for (var i = 0; i < args.Count; i++)
+ {
+ objArgs[i] = args[i];
+ }
+
+ ctx.ReportDiagnostic(Diagnostic.Create(
+ descriptor,
+ location,
+ properties,
+ objArgs));
+ }
+ }
+
+ private static ImmutableDictionary BuildProperties(
+ ShapeCheck check, INamedTypeSymbol symbol)
+ {
+ var props = check.FixProperties?.Invoke(symbol)
+ ?? ImmutableDictionary.Empty;
+
+ return props
+ .SetItem("fixKind", check.FixKind.ToString())
+ .SetItem("squiggleAt", check.SquiggleAt.ToString());
+ }
+
+ private static bool MatchesKind(TypeKindFilter filter, INamedTypeSymbol symbol) =>
+ filter switch
+ {
+ TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord,
+ TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord,
+ TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord,
+ TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord,
+ TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface,
+ TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface,
+ _ => false,
+ };
+
+ private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName)
+ {
+ foreach (var attribute in symbol.GetAttributes())
+ {
+ var fqn = attribute.AttributeClass?.ToDisplayString();
+ if (fqn is null)
+ {
+ continue;
+ }
+
+ if (string.Equals(fqn, metadataName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var bracket = fqn.IndexOf('<');
+ var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn;
+ if (string.Equals(bare, metadataName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Scribe/Shapes/Shape_T.cs b/Scribe/Shapes/Shape_T.cs
new file mode 100644
index 0000000..f32cfe6
--- /dev/null
+++ b/Scribe/Shapes/Shape_T.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Scribe.Attributes;
+using Scribe.Cache;
+
+namespace Scribe.Shapes;
+
+///
+/// A sealed shape typed with its projection . Produced
+/// by . In Phase 3 only
+/// is exposed; ToAnalyzer and ToFixProvider
+/// ship in Phase 4.
+///
+/// User projection type. Must be for cache correctness.
+public sealed partial class Shape where TModel : IEquatable
+{
+ private readonly TypeKindFilter _kind;
+ private readonly ShapeCheck[] _checks;
+ private readonly string? _primaryAttributeMetadataName;
+ private readonly ProjectionDelegate _project;
+
+ internal Shape(
+ TypeKindFilter kind,
+ ShapeCheck[] checks,
+ string? primaryAttributeMetadataName,
+ ProjectionDelegate project)
+ {
+ _kind = kind;
+ _checks = checks;
+ _primaryAttributeMetadataName = primaryAttributeMetadataName;
+ _project = project;
+ }
+
+ ///
+ /// Produce an of
+ /// — one per type in the compilation that
+ /// matches the shape's kind filter and (if declared) carries the driving
+ /// attribute. Violations of declared checks are carried on each result as
+ /// .
+ ///
+ public IncrementalValuesProvider> ToProvider(
+ IncrementalGeneratorInitializationContext context)
+ {
+ if (_primaryAttributeMetadataName is not null)
+ {
+ return context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ _primaryAttributeMetadataName,
+ predicate: (node, _) => MatchesNodeKind(node),
+ transform: TransformWithAttribute)
+ .Where(static s => s.HasValue)
+ .Select(static (s, _) => s!.Value);
+ }
+
+ return context.SyntaxProvider
+ .CreateSyntaxProvider(
+ predicate: (node, _) => MatchesNodeKind(node),
+ transform: TransformFromSyntax)
+ .Where(static s => s.HasValue)
+ .Select(static (s, _) => s!.Value);
+ }
+
+ private ShapedSymbol? TransformWithAttribute(
+ GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
+ {
+ if (ctx.TargetSymbol is not INamedTypeSymbol type)
+ {
+ return null;
+ }
+
+ return BuildResult(type, ctx.SemanticModel, ct);
+ }
+
+ private ShapedSymbol? TransformFromSyntax(
+ GeneratorSyntaxContext ctx, CancellationToken ct)
+ {
+ var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct);
+ if (symbol is not INamedTypeSymbol type)
+ {
+ return null;
+ }
+
+ return BuildResult(type, ctx.SemanticModel, ct);
+ }
+
+ private bool MatchesNodeKind(SyntaxNode node)
+ {
+ // Delegate to the builder's kind matcher by reconstructing a lightweight check.
+ return _kind switch
+ {
+ TypeKindFilter.Any => node is Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax,
+ _ => KindMatcher.Matches(_kind, node),
+ };
+ }
+
+ private bool MatchesSymbolKind(INamedTypeSymbol symbol) =>
+ _kind switch
+ {
+ TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord,
+ TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord,
+ TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord,
+ TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord,
+ TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface,
+ TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface,
+ _ => false,
+ };
+
+ private ShapedSymbol? BuildResult(
+ INamedTypeSymbol symbol, SemanticModel semanticModel, CancellationToken ct)
+ {
+ if (!MatchesSymbolKind(symbol))
+ {
+ return null;
+ }
+
+ var compilation = semanticModel.Compilation;
+ var violations = RunChecks(symbol, compilation, ct);
+
+ var attributeReader = _primaryAttributeMetadataName is not null
+ ? AttributeSchema.For(symbol, _primaryAttributeMetadataName)
+ : default;
+
+ var projectionContext = new ShapeProjectionContext(
+ symbol, attributeReader, semanticModel, compilation, ct);
+ var model = _project(in projectionContext);
+
+ var location = LocationInfo.From(FirstLocation(symbol));
+
+ return new ShapedSymbol(
+ Fqn: InternPool.Intern(symbol.ToDisplayString()),
+ Model: model,
+ Location: location,
+ Violations: violations);
+ }
+
+ private EquatableArray RunChecks(
+ INamedTypeSymbol symbol, Compilation compilation, CancellationToken ct)
+ {
+ if (_checks.Length == 0)
+ {
+ return EquatableArray.Empty;
+ }
+
+ List? violations = null;
+ foreach (var check in _checks)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (check.Predicate(symbol, compilation, ct))
+ {
+ continue;
+ }
+
+ violations ??= new List();
+ violations.Add(new DiagnosticInfo(
+ Id: check.Id,
+ Severity: check.Severity,
+ MessageArgs: check.MessageArgs(symbol),
+ Location: LocationInfo.From(FirstLocation(symbol))));
+ }
+
+ return violations is null
+ ? EquatableArray.Empty
+ : EquatableArray.From(violations);
+ }
+
+ private static Location? FirstLocation(INamedTypeSymbol symbol)
+ {
+ var locations = symbol.Locations;
+ return locations.Length == 0 ? null : locations[0];
+ }
+
+ private static class KindMatcher
+ {
+ public static bool Matches(TypeKindFilter kind, SyntaxNode node)
+ {
+ if (node is not Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax)
+ {
+ return false;
+ }
+
+ return kind switch
+ {
+ TypeKindFilter.Class =>
+ node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax,
+ TypeKindFilter.Record =>
+ node is Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax r
+ && r.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordDeclaration),
+ TypeKindFilter.RecordStruct =>
+ node is Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax rs
+ && rs.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordStructDeclaration),
+ TypeKindFilter.Struct =>
+ node is Microsoft.CodeAnalysis.CSharp.Syntax.StructDeclarationSyntax,
+ TypeKindFilter.Interface =>
+ node is Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax,
+ _ => false,
+ };
+ }
+ }
+}
diff --git a/Scribe/Shapes/ShapedPair.cs b/Scribe/Shapes/ShapedPair.cs
new file mode 100644
index 0000000..c8df8a4
--- /dev/null
+++ b/Scribe/Shapes/ShapedPair.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Scribe.Shapes;
+
+///
+/// A value-equatable pair of s produced by
+/// . Flows through
+///
+/// so that joined-pair consumers react only to actual pairing changes, not
+/// unrelated edits on either side.
+///
+public readonly record struct ShapedPair(
+ ShapedSymbol Left,
+ ShapedSymbol Right)
+ where TLeft : IEquatable
+ where TRight : IEquatable;
diff --git a/Scribe/Shapes/ShapedSymbol.cs b/Scribe/Shapes/ShapedSymbol.cs
new file mode 100644
index 0000000..a3a07d2
--- /dev/null
+++ b/Scribe/Shapes/ShapedSymbol.cs
@@ -0,0 +1,21 @@
+using System;
+using Scribe.Cache;
+
+namespace Scribe.Shapes;
+
+///
+/// A cache-safe projection of a single type matched by a .
+/// Flows through
+/// as the unit of change the generator downstream reacts to.
+///
+/// Consumer-supplied projection; must be value-equatable for cache correctness.
+/// Fully-qualified display name of the matched type (interned).
+/// User projection of the symbol — cache-safe by constraint.
+/// Location of the primary declaration, for downstream reporting.
+/// Collected s emitted by declared shape checks.
+public readonly record struct ShapedSymbol(
+ string Fqn,
+ TModel Model,
+ LocationInfo? Location,
+ EquatableArray Violations)
+ where TModel : IEquatable;
diff --git a/Scribe/Shapes/SquiggleAt.cs b/Scribe/Shapes/SquiggleAt.cs
new file mode 100644
index 0000000..390ff5a
--- /dev/null
+++ b/Scribe/Shapes/SquiggleAt.cs
@@ -0,0 +1,33 @@
+namespace Scribe.Shapes;
+
+///
+/// Closed enumeration of squiggle anchor points on a type declaration.
+/// Each primitive picks an opinionated default
+/// so the diagnostic lands in the most editorially-useful place.
+///
+public enum SquiggleAt
+{
+ /// The class / struct / record keyword token.
+ TypeKeyword,
+
+ /// The type's identifier (name) token.
+ Identifier,
+
+ /// The modifier list (e.g., public sealed).
+ ModifierList,
+
+ /// The base list (e.g., : IFoo, IDisposable).
+ BaseList,
+
+ /// The attribute list preceding the declaration.
+ AttributeList,
+
+ /// The containing namespace declaration.
+ ContainingNamespace,
+
+ /// The entire type declaration span.
+ EntireDeclaration,
+
+ /// The first member of the offending kind.
+ FirstMemberOfKind,
+}
diff --git a/Scribe/Shapes/SquiggleLocator.cs b/Scribe/Shapes/SquiggleLocator.cs
new file mode 100644
index 0000000..61f0af6
--- /dev/null
+++ b/Scribe/Shapes/SquiggleLocator.cs
@@ -0,0 +1,76 @@
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Shapes;
+
+///
+/// Resolves a anchor to a concrete on
+/// a type's primary declaration. Used by to
+/// land diagnostics at the most editorially-useful place.
+///
+internal static class SquiggleLocator
+{
+ public static Location Resolve(INamedTypeSymbol symbol, SquiggleAt anchor, CancellationToken ct)
+ {
+ var fallback = FirstLocationOrNone(symbol);
+ var refs = symbol.DeclaringSyntaxReferences;
+ if (refs.Length == 0)
+ {
+ return fallback;
+ }
+
+ var syntax = refs[0].GetSyntax(ct);
+ if (syntax is not TypeDeclarationSyntax tds)
+ {
+ return fallback;
+ }
+
+ return anchor switch
+ {
+ SquiggleAt.TypeKeyword => tds.Keyword.GetLocation(),
+ SquiggleAt.Identifier => tds.Identifier.GetLocation(),
+ SquiggleAt.ModifierList => ModifierListLocation(tds),
+ SquiggleAt.BaseList => tds.BaseList?.GetLocation() ?? tds.Identifier.GetLocation(),
+ SquiggleAt.AttributeList => tds.AttributeLists.FirstOrDefault()?.GetLocation() ?? tds.Identifier.GetLocation(),
+ SquiggleAt.ContainingNamespace => ContainingNamespaceLocation(tds) ?? tds.Identifier.GetLocation(),
+ SquiggleAt.EntireDeclaration => tds.GetLocation(),
+ SquiggleAt.FirstMemberOfKind => tds.Members.FirstOrDefault()?.GetLocation() ?? tds.Identifier.GetLocation(),
+ _ => tds.Identifier.GetLocation(),
+ };
+ }
+
+ private static Location ModifierListLocation(TypeDeclarationSyntax tds)
+ {
+ if (tds.Modifiers.Count == 0)
+ {
+ return tds.Keyword.GetLocation();
+ }
+
+ var first = tds.Modifiers.First();
+ var last = tds.Modifiers.Last();
+ var span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(first.Span.Start, last.Span.End);
+ return Location.Create(tds.SyntaxTree, span);
+ }
+
+ private static Location? ContainingNamespaceLocation(SyntaxNode node)
+ {
+ for (var current = node.Parent; current is not null; current = current.Parent)
+ {
+ switch (current)
+ {
+ case FileScopedNamespaceDeclarationSyntax fsn:
+ return fsn.Name.GetLocation();
+ case NamespaceDeclarationSyntax nsd:
+ return nsd.Name.GetLocation();
+ }
+ }
+
+ return null;
+ }
+
+ private static Location FirstLocationOrNone(INamedTypeSymbol symbol) =>
+ symbol.Locations.Length == 0 ? Location.None : symbol.Locations[0];
+}
diff --git a/Scribe/Shapes/TypeKindFilter.cs b/Scribe/Shapes/TypeKindFilter.cs
new file mode 100644
index 0000000..da106fe
--- /dev/null
+++ b/Scribe/Shapes/TypeKindFilter.cs
@@ -0,0 +1,15 @@
+namespace Scribe.Shapes;
+
+///
+/// Coarse type-kind filter applied at the syntax stage before semantic analysis.
+/// Picked by , , etc.
+///
+internal enum TypeKindFilter
+{
+ Class,
+ Record,
+ RecordStruct,
+ Struct,
+ Interface,
+ Any,
+}
diff --git a/Scribe/build/Scribe.LocalDev.props b/Scribe/build/Scribe.LocalDev.props
index fb98cec..307634b 100644
--- a/Scribe/build/Scribe.LocalDev.props
+++ b/Scribe/build/Scribe.LocalDev.props
@@ -2,40 +2,67 @@
@@ -49,16 +76,49 @@
<_ScribeLocalDevImported>true
-
-
+
+
+
+ $(ScribeName)
+
+
+
+
+
+ <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.user" />
+ <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.local" />
+
+
+ <_ScribeLocalSentinelList>@(_ScribeLocalSentinel)
+
+ and '$(_ScribeLocalSentinelList)' != ''">
true
+
+
+
+ <_ScribeProducerBase>$(ScribeRoot)\.$(ScribesName.ToLowerInvariant())
+
+
+ true
+
+
$(ScribeRoot)\.artifacts\
@@ -69,21 +129,27 @@
-
-
+
+
true
$(ScribePackagesDir)
-
+
diff --git a/Scribe/build/Scribe.LocalDev.targets b/Scribe/build/Scribe.LocalDev.targets
index b1c5382..1c233ab 100644
--- a/Scribe/build/Scribe.LocalDev.targets
+++ b/Scribe/build/Scribe.LocalDev.targets
@@ -2,25 +2,36 @@
@@ -33,16 +44,18 @@
<_ScribeLocalDevTargetsImported>true
-
+
-
- $(ScribeLocalDevTriggerProject)
-
-
-
-
+
-
-
-
<_ScribeDevVersion>$(NuGetPackageVersion)
<_ScribeDevVersion Condition="'$(_ScribeDevVersion)' == ''">$(PackageVersion)
- <_ScribeDevOverridePath>$(ScribeArtifactsDir)$(ScribeLocalDevOverrideName).Directory.Packages.targets
+ <_ScribeDevOverridePath>$(ScribeArtifactsDir)$(PackageId).Directory.Packages.targets
-
- <_ScribeDevPackage Include="$(ScribeLocalDevPackageNames)" />
-
-
<_ScribeDevLine Include="<Project>" />
<_ScribeDevLine Include=" <!-- Auto-generated by Scribe LocalDev — do not edit. Rebuild the producer to refresh. -->" />
<_ScribeDevLine Include=" <ItemGroup>" />
- <_ScribeDevLine Include=" <PackageVersion Update="%(_ScribeDevPackage.Identity)" Version="$(_ScribeDevVersion)" />" />
+ <_ScribeDevLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeDevVersion)" />" />
<_ScribeDevLine Include=" </ItemGroup>" />
<_ScribeDevLine Include="</Project>" />
@@ -117,4 +107,55 @@
Importance="high"
/>
+
+
+
+
+
+ <_ScribeCleanupOverride>$(ScribeArtifactsDir)$(PackageId).Directory.Packages.targets
+
+
+
+
+
+
+
+
+ <_ScribeStalePackage Include="$(ScribePackagesDir)$(PackageId).*-dev.*.nupkg" />
+ <_ScribeStalePackage Include="$(ScribePackagesDir)$(PackageId).*-dev.*.snupkg" />
+
+
+
+
+
diff --git a/Scribe/build/Scribe.SolutionAnalyzer.props b/Scribe/build/Scribe.SolutionAnalyzer.props
deleted file mode 100644
index 5f7b01e..0000000
--- a/Scribe/build/Scribe.SolutionAnalyzer.props
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
- <_ScribeSaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
- 0.0.0-dev.$(_ScribeSaTimestamp)
- 0.0.0-dev.$(_ScribeSaTimestamp)
-
-
- true
- true
-
-
- false
-
-
-
-
-
- <_ScribeSaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)'))
-
-
-
-
-
- $(_ScribeSaArtifactsDir)packages\
-
-
-
-
-
-
- $(RestoreAdditionalProjectSources);$(_ScribeSaArtifactsDir)packages
-
-
-
diff --git a/Scribe/build/Scribe.SolutionAnalyzer.targets b/Scribe/build/Scribe.SolutionAnalyzer.targets
deleted file mode 100644
index 8e83133..0000000
--- a/Scribe/build/Scribe.SolutionAnalyzer.targets
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
-
-
- <_ScribeSaVersion>$(NuGetPackageVersion)
- <_ScribeSaVersion Condition="'$(_ScribeSaVersion)' == ''">$(PackageVersion)
- <_ScribeSaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)'))
- <_ScribeSaOverridePath>$(_ScribeSaArtifactsDir)$(PackageId).Directory.Packages.targets
-
-
-
- <_ScribeSaLine Include="<Project>" />
- <_ScribeSaLine Include=" <!-- Auto-generated by Scribe SolutionAnalyzer — do not edit. Rebuild the analyzer to refresh. -->" />
- <_ScribeSaLine Include=" <ItemGroup>" />
- <_ScribeSaLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeSaVersion)" />" />
- <_ScribeSaLine Include=" </ItemGroup>" />
- <_ScribeSaLine Include="</Project>" />
-
-
-
-
-
-
-
diff --git a/docs/architecture-infrastructure.md b/docs/architecture-infrastructure.md
index b973bc1..18189b1 100644
--- a/docs/architecture-infrastructure.md
+++ b/docs/architecture-infrastructure.md
@@ -113,7 +113,7 @@ Solution-Local Analyzer and LocalDev use the same underlying mechanism but at di
| Concern | LocalDev | Solution-Local Analyzer |
| --------- | ---------- | ------------------------ |
| Scope | Cross-repo | Intra-solution |
-| Trigger | `.localscribe` sentinel | `ScribeSolutionAnalyzer=true` |
+| Trigger | `..user` or `..local` sentinel (e.g. `.scribe.user`, `.hermetic.local`) | `ScribeSolutionAnalyzer=true` |
| Version | NBGV + timestamp suffix | Timestamp-only (`0.0.0-dev.yyyyMMddHHmmss`) |
| Override files | Generated `.Directory.Packages.targets` | Same — generated `.Directory.Packages.targets` |
| Package directory | Shared `$(ScribeRoot)/.artifacts/packages/` | Solution `$(ArtifactsPath)packages/` |
@@ -169,23 +169,49 @@ All subsequent property groups and imports are conditioned on `'$(_ScribeLocalDe
### Sentinel File Detection
-Activates Local Scribe mode when a `.localscribe` file exists in `$(ScribeRoot)` (the shared workspace root). This works in both Visual Studio and CLI builds without build configuration hacks.
+Two tiers of activation — one per-producer, one umbrella:
+
+**Per-producer (`$(IsLocalProducer)`):** each producer declares `$(ScribesName)` in its Directory.Build.props (e.g. `Scribe`, `Hermetic`). EITHER `.$(ScribesName).user` OR `.$(ScribesName).local` (lowercased, e.g. `.hermetic.user` / `.hermetic.local`) at `$(ScribeRoot)` flips `$(IsLocalProducer)` on for that project. Producer mode gates auto-pack, timestamped versions, and override-file emission.
+
+```xml
+
+ <_ScribeProducerBase>$(ScribeRoot)\.$(ScribesName.ToLowerInvariant())
+
+
+ true
+
+```
+
+**Umbrella (`$(IsLocalScribe)`):** ANY `.*.user` or `.*.local` sentinel at `$(ScribeRoot)` flips `$(IsLocalScribe)` on. The umbrella flag governs consumer-side infrastructure: NuGet source registration and override-file imports.
```xml
-
+
+ <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.user" />
+ <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.local" />
+
+
+
+ <_ScribeLocalSentinelList>@(_ScribeLocalSentinel)
+
+
true
```
-Three activation methods:
+Three activation methods (umbrella):
+
+1. **Sentinel file (recommended):** Create `..user` OR `..local` in `$(ScribeRoot)` — e.g. `.scribe.user` to build Scribe locally, `.hermetic.local` to build Hermetic locally. Either form works. Delete to deactivate. Add to `.gitignore`.
+
+ **Why two forms?** `.user` is the .NET convention — `*.user` is in the standard [VisualStudio.gitignore](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore), so it won't be accidentally committed. `.local` reads more naturally and matches the JS ecosystem (Vite, Next.js). Both are accepted rather than forcing a choice between idioms.
-1. **Sentinel file (recommended):** Create `.localscribe` in `$(ScribeRoot)`. Delete it to deactivate. Add `.localscribe` to `.gitignore`.
-2. **Explicit property:** `-p:IsLocalScribe=true` on the command line.
+2. **Explicit property:** `-p:IsLocalScribe=true` on the command line — consumer infra only. Does not activate any specific producer; pair with a `-p:IsLocalProducer=true` or a sentinel.
3. **Props file:** `true` in Directory.Build.props or Directory.Solution.props.
+**Typo tolerance:** `$(ScribeName)` (non-possessive) forwards to `$(ScribesName)` if only the former is set.
+
### Path Setup
When active, derives the shared artifacts and packages directories from `$(ScribeRoot)`:
@@ -200,7 +226,7 @@ When active, derives the shared artifacts and packages directories from `$(Scrib
### Auto-Pack
-For the trigger project only, enables `GeneratePackageOnBuild` and redirects output to the shared packages directory:
+For the trigger project only — identified by `$(MSBuildProjectName) == $(ScribesName)` — and only when `$(IsLocalProducer) == true`, enables `GeneratePackageOnBuild` and redirects output to the shared packages directory:
```xml
true
@@ -229,7 +255,14 @@ Uses a wildcard import to pick up all override files from the shared artifacts d
### Override File Generation
-The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project only. It generates a `.Directory.Packages.targets` file containing `PackageVersion Update` entries:
+The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project only, gated on `$(IsLocalProducer) == true`. The file is named after `$(ScribesName)` so a consumer can load it by string-concat:
+
+```csharp
+$(ScribeArtifactsDir)$(ScribesName).Directory.Packages.targets
+// e.g. .artifacts/Hermetic.Directory.Packages.targets
+```
+
+Contents:
```xml
@@ -242,6 +275,15 @@ The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project on
The version is read from `$(NuGetPackageVersion)` (set by Nerdbank.GitVersioning or the SDK).
+### Cleanup on Sentinel Removal
+
+The `_ScribeLocalDevCleanupStale` target runs `BeforeTargets="Build"` on the trigger project when `$(IsLocalProducer) != true` and `$(ScribesName) != ''`. It deletes:
+
+- The stale `$(ScribesName).Directory.Packages.targets` override file (if present).
+- Stale `-dev.*` packages matching each ID in `$(ScribeLocalDevPackageNames)` (`.nupkg` and `.snupkg`).
+
+Cleanup is scoped to this producer's own artifacts only — other producers' files are untouched. Removing a producer's `..user` / `..local` sentinel and rebuilding the producer normally is enough to clean its dev packages out of the shared artifacts folder.
+
---
## Multi-Repo Chain
diff --git a/docs/project-setup.md b/docs/project-setup.md
index c6d2e29..e7fe5c1 100644
--- a/docs/project-setup.md
+++ b/docs/project-setup.md
@@ -262,16 +262,24 @@ In the consumer's `Directory.Build.props`, point `ScribeRoot` to the shared work
#### 2. Configure the producer
-In the producer's `Directory.Build.props`, set the trigger project and package names *before* importing Scribe's LocalDev files:
+In the producer's `Directory.Build.props`, set the producer name and package IDs *before* importing Scribe's LocalDev files:
```xml
$(MSBuildThisFileDirectory)..
- MyFramework
+ MyFramework
MyFramework
```
+`$(ScribesName)` drives three things:
+
+- **Sentinel detection:** matches `.$(ScribesName).user` OR `.$(ScribesName).local` (lowercased, e.g. `.myframework.user` or `.myframework.local`) at `$(ScribeRoot)`. Either form activates — pick whichever you prefer.
+- **Auto-pack trigger:** the MSBuild project whose name equals `$(ScribesName)` is the one that packs.
+- **Override file name:** generated as `$(ScribesName).Directory.Packages.targets`.
+
+`$(ScribeName)` (non-possessive) is accepted as a typo-tolerant alias.
+
#### 3. Import the LocalDev files
If you consume Scribe via NuGet, the import happens automatically (NuGet auto-imports `build/BulletsForHumanity.Scribe.props` and `.targets`).
@@ -290,17 +298,23 @@ For direct import from a sibling checkout:
#### 4. Activate
-Three ways to activate LocalDev:
+Activation is **per-producer**:
-1. **Sentinel file (recommended):** Create a `.localscribe` file in `$(ScribeRoot)` (the shared workspace root). Delete it to deactivate. Add `.localscribe` to `.gitignore`.
-2. **MSBuild property:** `dotnet build -p:IsLocalScribe=true`
+1. **Sentinel file (recommended):** Create EITHER `.$(ScribesName).user` OR `.$(ScribesName).local` at `$(ScribeRoot)` — for example `.myframework.user` or `.myframework.local`. Either form works; pick whichever you prefer. Delete to deactivate. Multiple producers can be activated independently (e.g. `.scribe.user` + `.hermetic.local`). Already `.gitignore`d via the `*.user` and `*.local` patterns.
+
+ **Why two forms?** `.user` is the long-standing .NET convention — `*.user` is already in the standard [VisualStudio.gitignore](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore) so it won't be accidentally committed in any .NET repo. `.local` reads more naturally ("myframework, local variant") and matches the convention used by the JS ecosystem (Vite, Next.js, etc.). Rather than force a choice between "idiomatic .NET" and "idiomatic elsewhere," both are accepted.
+
+2. **MSBuild property:** `dotnet build -p:IsLocalScribe=true` — activates consumer infra only; pair with a sentinel or `-p:IsLocalProducer=true` to enable a producer.
3. **Props file:** Set `true` in `Directory.Build.props` or `Directory.Solution.props`.
+Building a producer **without** its sentinel triggers automatic cleanup: the producer's stale `-dev.*` packages and its override file are deleted from `.artifacts/`.
+
### Provided Properties
| Property | Value when active |
| ---------- | ------------------- |
-| `$(IsLocalScribe)` | `true` |
+| `$(IsLocalScribe)` | `true` when *any* `.*.user` or `.*.local` sentinel exists (umbrella — consumer infra) |
+| `$(IsLocalProducer)` | `true` when `.$(ScribesName).user` or `.$(ScribesName).local` exists (per-producer — pack + overrides) |
| `$(ScribeArtifactsDir)` | `$(ScribeRoot)\.artifacts\` |
| `$(ScribePackagesDir)` | `$(ScribeArtifactsDir)packages\` |
@@ -309,9 +323,8 @@ Three ways to activate LocalDev:
| Property | Set by | Purpose |
| ---------- | -------- | --------- |
| `$(ScribeRoot)` | Consumer | Shared workspace root |
-| `$(ScribeLocalDevTriggerProject)` | Producer | MSBuild project name that triggers auto-pack and override generation |
+| `$(ScribesName)` | Producer | Producer name — drives sentinel detection, auto-pack trigger, and override file name. `$(ScribeName)` is accepted as a typo alias. |
| `$(ScribeLocalDevPackageNames)` | Producer | Semicolon-separated NuGet package IDs to include in the override file |
-| `$(ScribeLocalDevOverrideName)` | Producer (optional) | Override file name prefix — defaults to `$(ScribeLocalDevTriggerProject)` |
### Chaining
diff --git a/docs/solution-local-analyzers.md b/docs/solution-local-analyzers.md
deleted file mode 100644
index 9f8f1f2..0000000
--- a/docs/solution-local-analyzers.md
+++ /dev/null
@@ -1,260 +0,0 @@
-# Solution-Local Analyzers
-
-How to set up Roslyn analyzers and source generators that live inside the same solution they serve — no NuGet publishing, no cross-repo setup.
-
----
-
-## Overview
-
-A **solution-local analyzer** is a Roslyn analyzer or source generator project that exists inside the same solution as the projects it serves. It is not published to NuGet. It is not consumed cross-repo. It automates *this* codebase and nothing else.
-
-Examples:
-
-- A generator that reads domain model types and emits mapping code, DTOs, or validation rules
-- An analyzer that enforces solution-specific coding conventions
-- A generator that emits strongly-typed configuration accessors from appsettings schema
-
-The Scribe SDK provides first-class support for this workflow. When `ScribeSolutionAnalyzer=true` is set on a Scribe SDK project, the SDK automatically:
-
-1. **Packs the analyzer on every build** — produces a `.nupkg` in the solution's `artifacts/packages/` directory
-2. **Generates a unique timestamp version** (`0.0.0-dev.yyyyMMddHHmmss`) — forces Visual Studio to reload the analyzer on every build
-3. **Generates a version override file** — consuming projects automatically resolve the locally-built package via the same mechanism as [LocalDev](project-setup.md#local-development-localdev)
-4. **Bundles private dependencies** — the same dependency bundling as any Scribe SDK project
-
-Consuming projects reference the analyzer via standard ``, getting all the same analyzer wiring that any published package gets.
-
----
-
-## Quick Start
-
-### 1. Create the analyzer project
-
-```xml
-
-
- true
-
-
-
-
-
-
-```
-
-### 2. Configure the solution for local packages
-
-The solution must use artifact output. In `Directory.Build.props`:
-
-```xml
-
- true
- $(MSBuildThisFileDirectory).artifacts/
-
-
-
-
-
- $(RestoreAdditionalProjectSources);$(ArtifactsPath)packages
-
-
-```
-
-In `Directory.Build.targets`, import override files generated by solution-local analyzers:
-
-```xml
-
-```
-
-### 3. Reference from consuming projects
-
-Add the analyzer to your `Directory.Packages.props` (any version — the override file pins the actual version):
-
-```xml
-
-
-```
-
-Then reference it from consuming projects:
-
-```xml
-
-
-
-```
-
-### 4. Build
-
-```shell
-dotnet build MySolution.sln
-```
-
-The analyzer auto-packs on build. The version override file is generated automatically. Consuming projects resolve the package from the artifacts directory. Diagnostics and generated code appear automatically.
-
----
-
-## How It Works
-
-### Auto-Pack on Build
-
-When `ScribeSolutionAnalyzer=true`, the SDK sets `GeneratePackageOnBuild=true` and redirects `PackageOutputPath` to `$(ArtifactsPath)packages/`. Every build produces a fresh `.nupkg`.
-
-### Timestamp Version
-
-Each build produces a unique version: `0.0.0-dev.yyyyMMddHHmmss` (e.g. `0.0.0-dev.20260406054112`). This serves two purposes:
-
-1. **Forces Visual Studio to reload** — VS detects a new analyzer version and reloads the analyzer assembly, picking up changes immediately
-2. **Eliminates NuGet cache issues** — each version is unique, so NuGet never serves stale cached content
-
-### Version Override File
-
-After each pack, the SDK generates a `.Directory.Packages.targets` file in the artifacts directory:
-
-```xml
-
-
-
-
-
-
-```
-
-This is the same mechanism as [LocalDev](project-setup.md#local-development-localdev). Consuming projects import the file via wildcard from `$(ArtifactsPath)`, which updates the `PackageVersion` to match the freshly-built package.
-
-### Package Directory
-
-Packages are placed in `$(ArtifactsPath)packages/` — the standard artifacts packages directory. This requires the solution to use artifact output (`UseArtifactsOutput=true`), which is the recommended setup for modern .NET projects.
-
----
-
-## Solution Layout
-
-A typical solution with a solution-local analyzer:
-
-```text
-MySolution/
- MySolution.sln
- Directory.Build.props ← UseArtifactsOutput, RestoreAdditionalProjectSources
- Directory.Build.targets ← Import override files from .artifacts/
- Directory.Packages.props ← PackageVersion Include for MyAnalyzer
- .gitignore ← includes .artifacts/
- .artifacts/ ← auto-generated, git-ignored
- packages/
- MyAnalyzer.0.0.0-dev.20260406054112.nupkg
- MyAnalyzer.Directory.Packages.targets ← version override file
- MyAnalyzer/
- MyAnalyzer.csproj ← ScribeSolutionAnalyzer=true
- MyGenerator.cs
- MyApp/
- MyApp.csproj ←
- Program.cs
-```
-
----
-
-## Multiple Analyzers
-
-A solution can have multiple solution-local analyzers. Each generates its own override file in the artifacts directory:
-
-```xml
-
-
-
- true
-
- ...
-
-```
-
-```xml
-
-
-
- true
-
- ...
-
-```
-
-The wildcard import in `Directory.Build.targets` picks up all override files:
-
-```csharp
-.artifacts/
- FirstAnalyzer.Directory.Packages.targets
- SecondAnalyzer.Directory.Packages.targets
- packages/
- FirstAnalyzer.0.0.0-dev.20260406054112.nupkg
- SecondAnalyzer.0.0.0-dev.20260406054112.nupkg
-```
-
----
-
-## Relationship to LocalDev
-
-Solution-local analyzers use the **same mechanism** as LocalDev (version override files + NuGet source registration) but scoped to a single solution:
-
-| Concern | LocalDev | Solution-Local Analyzer |
-| --------- | ---------- | ------------------------ |
-| Scope | Cross-repo (e.g. Scribe → Hermetic → MyApp) | Intra-solution |
-| Trigger | `.localscribe` sentinel file | `ScribeSolutionAnalyzer=true` property |
-| Published? | Yes (eventually to NuGet) | Never |
-| Version management | NBGV + timestamp + override files | Timestamp-only + override files |
-| Package directory | Shared `$(ScribeRoot)/.artifacts/packages/` | Solution `$(ArtifactsPath)packages/` |
-| Override file location | `$(ScribeRoot)/.artifacts/` | `$(ArtifactsPath)` |
-| Setup complexity | `$(ScribeRoot)`, trigger project, package names | One property + consumer-side imports |
-
-Both features are independent and can coexist. A solution can have solution-local analyzers and also participate in a LocalDev chain.
-
----
-
-## Provided Properties
-
-| Property | Value | Set by |
-| ---------- | ------- | -------- |
-| `$(ScribeSolutionAnalyzer)` | `true` | Developer (in `.csproj`) |
-| `$(Version)` | `0.0.0-dev.yyyyMMddHHmmss` | SDK (timestamp-based) |
-| `$(PackageVersion)` | `0.0.0-dev.yyyyMMddHHmmss` | SDK (same as Version) |
-| `$(GeneratePackageOnBuild)` | `true` | SDK |
-| `$(PackageOutputPath)` | `$(ArtifactsPath)packages\` | SDK |
-
----
-
-## Design Decisions
-
-| Decision | Choice | Rationale |
-| ---------- | -------- | ----------- |
-| Detection mechanism | Explicit `ScribeSolutionAnalyzer=true` property | Simple, discoverable, no magic. Convention-based detection would be ambiguous. |
-| Version strategy | Timestamp-based `0.0.0-dev.yyyyMMddHHmmss` | Unique version per build forces Visual Studio to reload analyzers. Eliminates NuGet cache issues. |
-| Package directory | `$(ArtifactsPath)packages/` | Uses the solution's existing artifact output directory. No separate `.packages/` directory needed. |
-| Override files | Same mechanism as LocalDev | Proven pattern — `PackageVersion Update` entries in auto-generated `.Directory.Packages.targets` files, imported via wildcard. |
-| Consumer-side setup | Manual one-time setup (`Directory.Build.props` + `.targets`) | Same pattern as LocalDev. All projects in the solution share the same `Directory.Build.*` files, so one-time setup covers everything. |
-
----
-
-## Troubleshooting
-
-### Consuming project doesn't see the analyzer
-
-1. Verify `$(ArtifactsPath)packages/` contains a `.nupkg` for the analyzer
-2. Verify `Directory.Build.props` registers `$(ArtifactsPath)packages` in `RestoreAdditionalProjectSources`
-3. Verify `Directory.Build.targets` imports `$(ArtifactsPath)*.Directory.Packages.targets`
-4. Run `dotnet restore` on the consuming project
-5. In Visual Studio, try **Build → Rebuild Solution**
-
-### Stale generated code after analyzer changes
-
-Each build produces a unique timestamp version, which forces Visual Studio to reload the analyzer. If you still see stale output:
-
-1. Close and reopen the solution in Visual Studio
-2. Run `dotnet restore && dotnet build`
-
-### ArtifactsPath is not set
-
-The solution must use artifact output. Add to `Directory.Build.props`:
-
-```xml
-
- true
- $(MSBuildThisFileDirectory).artifacts/
-
-```