diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 3a5e200..5e01fd2 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -41,6 +41,8 @@ My purpose here is singular: to bring Scribe into the world in its highest possi
| Document | What it covers |
| --- | --- |
| [README](../README.md) | Overview, quick start, component table, doc index |
+| [Glossary](../docs/glossary.md) | Canonical definitions of Shape DSL vocabulary (Shape, Focus, Lens, Projection, Violation, Relation) and text-emission primitives |
+| [The Shape DSL](../docs/dsl.md) | Overview of Scribe's query language — building blocks, composition rules, fluent vs query-comprehension form, three-consumer materialisation |
| [Writing Generators](../docs/writing-generators.md) | Transform -> Register -> Render pattern, Quill usage guide |
| [Project Setup](../docs/project-setup.md) | .csproj configuration, packaging, Stubs, LocalDev multi-repo workflow with automatic local NuGet resolution |
| [Quill Reference](../docs/quill-reference.md) | Complete Quill API reference |
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..cab7451
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
index 337996d..0fbd138 100644
--- a/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
+++ b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
@@ -7,9 +7,9 @@ namespace Scribe.Ink.Shapes;
///
/// SCRIBE200 — forbid Roslyn reference types on any TModel passed to
-/// Shape<TModel>.Project<TModel>. Holding an ISymbol,
+/// TypeShape.Etch<TModel>. Holding an ISymbol,
/// SyntaxNode, Compilation, SemanticModel, SyntaxTree,
-/// Location, or AttributeData in a cached model defeats the
+/// Location, or AttributeData in an etched model defeats the
/// incremental generator cache: those objects identity-compare per compilation.
/// Extract the primitive data you need into strings, equatable arrays, or
/// instead.
@@ -21,8 +21,8 @@ public sealed class CacheCorrectnessAnalyzer : DiagnosticAnalyzer
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.",
+ title: "Cache-hostile type in etched Shape model",
+ messageFormat: "Member '{0}' of type '{1}' is a Roslyn reference type ('{2}') — storing it in an etched Shape model defeats incremental caching. Extract primitives or use LocationInfo instead.",
category: "Scribe.Cache",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
@@ -39,8 +39,8 @@ public override void Initialize(AnalysisContext context)
private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
- var shapeBuilder = context.Compilation.GetTypeByMetadataName("Scribe.Shapes.ShapeBuilder");
- if (shapeBuilder is null)
+ var typeShape = context.Compilation.GetTypeByMetadataName("Scribe.Shapes.TypeShape");
+ if (typeShape is null)
{
return;
}
@@ -52,7 +52,7 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context)
}
context.RegisterOperationAction(
- ctx => AnalyzeInvocation(ctx, shapeBuilder, forbidden),
+ ctx => AnalyzeInvocation(ctx, typeShape, forbidden),
OperationKind.Invocation);
}
@@ -81,7 +81,7 @@ void Add(string metadataName)
private static void AnalyzeInvocation(
OperationAnalysisContext context,
- INamedTypeSymbol shapeBuilder,
+ INamedTypeSymbol typeShape,
ImmutableArray forbidden)
{
if (context.Operation is not IInvocationOperation invocation)
@@ -90,9 +90,9 @@ private static void AnalyzeInvocation(
}
var method = invocation.TargetMethod;
- if (method.Name != "Project"
+ if (method.Name != "Etch"
|| method.TypeArguments.Length != 1
- || !SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, shapeBuilder))
+ || !SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, typeShape))
{
return;
}
diff --git a/Scribe.Ink/Shapes/IShapeCustomFix.cs b/Scribe.Ink/Shapes/IShapeCustomFix.cs
index 3f7ba81..2dd7a08 100644
--- a/Scribe.Ink/Shapes/IShapeCustomFix.cs
+++ b/Scribe.Ink/Shapes/IShapeCustomFix.cs
@@ -8,8 +8,8 @@ namespace Scribe.Ink.Shapes;
/// User-supplied fix handler for diagnostics emitted with
/// . Registered on a shape via
/// under a
-/// customFixTag that the
-/// or declaration carried.
+/// customFixTag that the
+/// or declaration carried.
///
///
///
diff --git a/Scribe.Ink/Shapes/ShapeInkExtensions.cs b/Scribe.Ink/Shapes/ShapeInkExtensions.cs
index a26db49..1f3a2e9 100644
--- a/Scribe.Ink/Shapes/ShapeInkExtensions.cs
+++ b/Scribe.Ink/Shapes/ShapeInkExtensions.cs
@@ -19,7 +19,7 @@ public static class ShapeInkExtensions
/// instance in a concrete [ExportCodeFixProvider]-attributed class
/// that delegates its members for deployment.
///
- public static CodeFixProvider ToFixProvider(this Shape shape)
+ public static CodeFixProvider ToInk(this Shape shape)
where TModel : IEquatable
{
if (shape is null)
@@ -46,7 +46,7 @@ public static CodeFixProvider ToFixProvider(this Shape shape)
/// Diagnostics whose fixKind is and whose
/// customFixTag property matches will be routed
/// to by the provider returned from
- /// .
+ /// .
///
public static Shape WithCustomFix(
this Shape shape, string tag, IShapeCustomFix fix)
diff --git a/Scribe.Scriptorium/Scribe.Scriptorium.csproj b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
index 576754b..cdbfba1 100644
--- a/Scribe.Scriptorium/Scribe.Scriptorium.csproj
+++ b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
@@ -1,13 +1,13 @@
Meta
BulletsForHumanity.Scribe.Scriptorium
- Build-time-only meta-generator that emits Should*/Could*/ShouldNot* variants of each Must* primitive on Scribe's ShapeBuilder. Consumed solution-internally by Scribe via local -dev PackageReference for VS analyzer hot-refresh.
+ Build-time-only meta-generator that emits Should*/Could*/ShouldNot* variants of each Must* primitive on Scribe's TypeShape. Consumed solution-internally by Scribe via local -dev PackageReference for VS analyzer hot-refresh.
diff --git a/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs b/Scribe.Scriptorium/TypeShapeVariantsGenerator.cs
similarity index 93%
rename from Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs
rename to Scribe.Scriptorium/TypeShapeVariantsGenerator.cs
index 3562397..9a5f8e4 100644
--- a/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs
+++ b/Scribe.Scriptorium/TypeShapeVariantsGenerator.cs
@@ -9,7 +9,7 @@ namespace Scribe.Scriptorium;
///
/// Meta-generator. Runs at Scribe's own compile time. Scans
-/// Scribe.Shapes.ShapeBuilder for every Must* primitive and
+/// Scribe.Shapes.TypeShape for every Must* primitive and
/// emits severity variants into a partial class:
///
/// - Must* (positive) → Should* (Warning) + Could* (Info)
@@ -19,9 +19,9 @@ namespace Scribe.Scriptorium;
/// only the severity when the caller has not supplied one.
///
[Generator(LanguageNames.CSharp)]
-public sealed class ShapeBuilderVariantsGenerator : IIncrementalGenerator
+public sealed class TypeShapeVariantsGenerator : IIncrementalGenerator
{
- private const string ShapeBuilderMetadataName = "Scribe.Shapes.ShapeBuilder";
+ private const string TypeShapeMetadataName = "Scribe.Shapes.TypeShape";
private const string DiagnosticSpecFqn = "Scribe.Shapes.DiagnosticSpec";
// FullyQualifiedFormat minus UseSpecialTypes, so `System.String` doesn't collapse to the
@@ -42,14 +42,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
{
if (!string.IsNullOrEmpty(src))
{
- spc.AddSource("ShapeBuilder.Variants.g.cs", src!);
+ spc.AddSource("TypeShape.Variants.g.cs", src!);
}
});
}
private static string? BuildSource(Compilation compilation, CancellationToken ct)
{
- var shapeBuilder = compilation.GetTypeByMetadataName(ShapeBuilderMetadataName);
+ var shapeBuilder = compilation.GetTypeByMetadataName(TypeShapeMetadataName);
if (shapeBuilder is null)
{
return null;
@@ -74,7 +74,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
sb.AppendLine();
sb.AppendLine("namespace Scribe.Shapes;");
sb.AppendLine();
- sb.AppendLine("partial class ShapeBuilder");
+ sb.AppendLine("partial class TypeShape");
sb.AppendLine("{");
var first = true;
@@ -108,7 +108,7 @@ private static bool IsVariantTarget(IMethodSymbol method)
return false;
}
- if (method.ReturnType.ToDisplayString() != ShapeBuilderMetadataName)
+ if (method.ReturnType.ToDisplayString() != TypeShapeMetadataName)
{
return false;
}
@@ -164,7 +164,7 @@ private static void EmitOne(
sb.Append(" /// ").Append(summary).AppendLine("");
- sb.Append(" public global::Scribe.Shapes.ShapeBuilder ").Append(newName);
+ sb.Append(" public global::Scribe.Shapes.TypeShape ").Append(newName);
AppendTypeParameters(sb, method);
sb.Append('(');
AppendParameterList(sb, method);
@@ -310,7 +310,7 @@ private static string FormatDefault(object? value, ITypeSymbol type)
private static string FormatXmlRef(IMethodSymbol method)
{
var builder = new StringBuilder();
- builder.Append("Scribe.Shapes.ShapeBuilder.").Append(method.Name);
+ builder.Append("Scribe.Shapes.TypeShape.").Append(method.Name);
if (method.TypeParameters.Length > 0)
{
builder.Append('{');
diff --git a/Scribe.Sdk/Sdk/Phases/Meta.targets b/Scribe.Sdk/Sdk/Phases/Meta.targets
index 7c52950..76bca19 100644
--- a/Scribe.Sdk/Sdk/Phases/Meta.targets
+++ b/Scribe.Sdk/Sdk/Phases/Meta.targets
@@ -51,35 +51,40 @@
true
-
-
-
-
- <_ScribeMetaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
- 0.0.0-dev.$(_ScribeMetaTimestamp)
- 0.0.0-dev.$(_ScribeMetaTimestamp)
- false
+
+
+
+ $(ScribeRoot)\.artifacts\
+ $(ScribeArtifactsDir)packages\
-
-
-
- <_ScribeMetaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)'))
- $(_ScribeMetaArtifactsDir)packages\
+
+
+
+ true
+ $(ScribePackagesDir)
+ true
-
-
- $(RestoreAdditionalProjectSources);$(_ScribeMetaArtifactsDir)packages
+ exists — NuGet emits NU1301 ("The local source doesn't exist") otherwise.
+ The Pack target below creates the directory; subsequent restores pick up. -->
+
+ $(RestoreAdditionalProjectSources);$(ScribePackagesDir)
@@ -113,14 +118,19 @@
+
<_ScribeMetaOverrideVersion>$(NuGetPackageVersion)
<_ScribeMetaOverrideVersion Condition="'$(_ScribeMetaOverrideVersion)' == ''">$(PackageVersion)
- <_ScribeMetaOverridePath>$(_ScribeMetaArtifactsDir)$(PackageId).Directory.Packages.targets
+ <_ScribeMetaOverridePath>$(ScribeArtifactsDir)$(PackageId).Directory.Packages.targets
<_ScribeMetaOverrideLine Include="<Project>" />
@@ -130,7 +140,7 @@
<_ScribeMetaOverrideLine Include=" </ItemGroup>" />
<_ScribeMetaOverrideLine Include="</Project>" />
-
+
to the Meta's -dev.* nupkg, pulled from
- $(ScribePackagesDir) via NuGet.config's LocalArtifacts source.
- VersionOverride="0.0.0-dev.*" floats to the latest timestamped
- package, which is how VS reliably refreshes the analyzer load
- context when the Meta source changes.
+ Local dev → to the Meta's -dev.* nupkg, pulled from
+ $(ScribePackagesDir) via the LocalScribe packages source.
+ VersionOverride="*-*" floats to the latest timestamped
+ package, which is how VS reliably refreshes the analyzer
+ load context when the Meta source changes.
- Release → . CI-safe — no dependency on
- a pre-pack step; the build graph orders Meta before its
- consumer naturally.
+ CI → . No bootstrap dependency
+ on a pre-pack step; the build graph orders Meta before its
+ consumer naturally.
+
+ The switch is $(ContinuousIntegrationBuild) — the real axis ("is a build
+ server driving this"), not $(Configuration) which can diverge from CI
+ intent. Directory.Build.props sets $(ContinuousIntegrationBuild)=true
+ when $(GITHUB_ACTIONS)=true; override manually for other CI systems.
Intended for intra-solution Metas only. Cross-repo consumers install the
published Meta nupkg directly — no ScribeMeta wrapper needed.
@@ -36,24 +41,23 @@
Condition="'%(ScribeMeta.Identity)' != '' and '%(ScribeMeta.PackageId)' == ''" />
-
-
+
-
-
+
+
diff --git a/Scribe.Tests/Shapes/AsTypeShapeLensTests.cs b/Scribe.Tests/Shapes/AsTypeShapeLensTests.cs
new file mode 100644
index 0000000..5db3c33
--- /dev/null
+++ b/Scribe.Tests/Shapes/AsTypeShapeLensTests.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises AsTypeShape() on and
+/// — re-entering the type-shape world
+/// rooted at a generic argument or a base-chain step. Validates that nested
+/// type-level lenses (attributes, members, base-type-chain) fire against the
+/// inner type and that non-named-type generic arguments pass silently.
+///
+public class AsTypeShapeLensTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void AsTypeShape_on_type_arg_enables_nested_attribute_check_on_the_argument_type()
+ {
+ // Shape requires: [Thing] on Widget → the T itself must carry [System.Obsolete].
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.GenericTypeArg(index: 0, configure: arg =>
+ arg.AsTypeShape(t =>
+ t.Attributes("System.ObsoleteAttribute", min: 1))))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // Payload is NOT marked Obsolete → inner attributes min:1 fires on its TypeFocus.
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+public class Payload { }
+[Thing] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE050");
+ }
+
+ [Fact]
+ public void AsTypeShape_on_type_arg_is_silent_when_nested_constraint_is_satisfied()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.GenericTypeArg(index: 0, configure: arg =>
+ arg.AsTypeShape(t =>
+ t.Attributes("System.ObsoleteAttribute", min: 1))))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+[System.Obsolete(""deprecated"")] public class Payload { }
+[Thing] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void AsTypeShape_on_type_arg_silent_passes_for_non_named_type_arguments()
+ {
+ // T = int[] (an array, IArrayTypeSymbol not INamedTypeSymbol) → navigation yields
+ // no TypeFocus → inner Attributes(...) min:1 has 0 parents to fire against →
+ // silent pass.
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.GenericTypeArg(index: 0, configure: arg =>
+ arg.AsTypeShape(t =>
+ t.Attributes("System.ObsoleteAttribute", min: 1))))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+[Thing] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void AsTypeShape_on_base_chain_enables_nested_attribute_check_on_each_base()
+ {
+ // Require every base in the chain to carry [System.Obsolete]. Gadget doesn't.
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(configure: step =>
+ step.AsTypeShape(t =>
+ t.Attributes("System.ObsoleteAttribute", min: 1)))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Gadget { }
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ // Widget's base chain = [Gadget] → Gadget has no Obsolete → 1 violation.
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE050");
+ }
+
+ [Fact]
+ public void AsTypeShape_on_base_chain_is_silent_when_every_base_satisfies_nested_constraint()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(configure: step =>
+ step.AsTypeShape(t =>
+ t.Attributes("System.ObsoleteAttribute", min: 1)))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+[System.Obsolete(""d"")] public class Gadget { }
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ 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/AttributeSubLensTests.cs b/Scribe.Tests/Shapes/AttributeSubLensTests.cs
new file mode 100644
index 0000000..6603dfd
--- /dev/null
+++ b/Scribe.Tests/Shapes/AttributeSubLensTests.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises the attribute sub-lenses — GenericTypeArg, ConstructorArg<T>,
+/// and NamedArg<T> — end-to-end against the analyzer materialisation
+/// path. Validates navigation, presence constraints, and silent-pass semantics
+/// when an argument does not match the requested shape.
+///
+public class AttributeSubLensTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void ConstructorArg_min1_emits_violation_when_attribute_has_no_matching_arg_at_index()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // [Obsolete] with no arguments — ConstructorArg(0) yields nothing.
+ var source = "[System.Obsolete] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE054");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 0");
+ }
+
+ [Fact]
+ public void ConstructorArg_min1_is_silent_when_matching_arg_is_present()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ConstructorArg_type_mismatch_yields_silent_pass()
+ {
+ // Arg is string but we ask for int → TryCoerce fails → 0 observed.
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE054");
+ }
+
+ [Fact]
+ public void NamedArg_min1_emits_violation_when_named_arg_is_absent()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.NamedArg(name: "DiagnosticId", min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE055");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("DiagnosticId");
+ }
+
+ [Fact]
+ public void NamedArg_min1_is_silent_when_named_arg_is_present()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.NamedArg(name: "DiagnosticId", min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\", DiagnosticId = \"X1\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void GenericTypeArg_min1_is_silent_when_generic_arg_is_present()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.GenericTypeArg(index: 0, min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { }
+[Thing] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ConstructorArg_negative_index_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeRecord().Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: -1)));
+ }
+
+ [Fact]
+ public void NamedArg_empty_name_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeRecord().Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.NamedArg(name: "")));
+ }
+
+ 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/AttributesLensTests.cs b/Scribe.Tests/Shapes/AttributesLensTests.cs
new file mode 100644
index 0000000..0f7cd2f
--- /dev/null
+++ b/Scribe.Tests/Shapes/AttributesLensTests.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises the TypeShape.Attributes(fqn, min, max) entry point end-to-end
+/// against the analyzer materialisation path. Validates that the lens navigates
+/// matching attribute applications, that presence constraints emit a diagnostic
+/// at the parent type's span, and that the custom presence spec is honoured.
+///
+public class AttributesLensTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ // Uses System.ObsoleteAttribute so the test source only contains a single class (Widget).
+ // Attribute-derived classes in the source would otherwise be matched by ExposeClass()
+ // and produce their own presence-violation diagnostics.
+
+ [Fact]
+ public void Attributes_min1_emits_presence_violation_when_no_attribute_is_applied()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes("System.ObsoleteAttribute", min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE050");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("ObsoleteAttribute");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 0");
+ }
+
+ [Fact]
+ public void Attributes_min1_is_silent_when_attribute_is_applied()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes("System.ObsoleteAttribute", min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete] public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Attributes_max1_emits_presence_violation_when_attribute_is_applied_twice()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes("ThingAttribute", min: 0, max: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)]
+public sealed class ThingAttribute : System.Attribute { }
+[Thing][Thing] public class Widget { }
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ // ThingAttribute has 0 Thing applications (passes max:1); Widget has 2 (violates).
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE050");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 2");
+ }
+
+ [Fact]
+ public void Attributes_custom_presence_spec_overrides_id_and_severity()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes(
+ "System.ObsoleteAttribute",
+ min: 1,
+ presenceSpec: new DiagnosticSpec(Id: "CUST100", Severity: DiagnosticSeverity.Warning))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("CUST100");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ }
+
+ [Fact]
+ public void Attributes_presence_violation_squiggles_at_type_origin()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes("System.ObsoleteAttribute", min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Location.SourceSpan.Length.ShouldBeGreaterThan(0);
+ }
+
+ 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/BaseTypeChainLensTests.cs b/Scribe.Tests/Shapes/BaseTypeChainLensTests.cs
new file mode 100644
index 0000000..7b3cc93
--- /dev/null
+++ b/Scribe.Tests/Shapes/BaseTypeChainLensTests.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises TypeShape.BaseTypeChain(configure, min, max) end-to-end against
+/// the analyzer materialisation path. Validates inheritance navigation, excludes
+/// from the chain, and enforces presence constraints.
+///
+public class BaseTypeChainLensTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ // Tests target record types (ExposeRecord) so the `class Gadget { }` base in the
+ // source is filtered out by TypeKindFilter before checks/lens branches run — no
+ // stray diagnostics on the base type contaminate the test assertions.
+
+ [Fact]
+ public void BaseTypeChain_min1_emits_violation_when_type_inherits_directly_from_object()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE052");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 0");
+ }
+
+ [Fact]
+ public void BaseTypeChain_min1_is_silent_when_type_inherits_from_a_non_object_base()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // Gadget is a class (not a record), so it's filtered out by ExposeRecord.
+ // Only Widget is evaluated, and it has 1 base → passes.
+ var source = @"
+public class Gadget { }
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void BaseTypeChain_max0_emits_violation_when_type_has_a_non_object_base()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(max: 0)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Gadget;
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ // Widget has 1 base; Gadget has 0 (passes max:0). Only Widget emits.
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE052");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 1");
+ }
+
+ [Fact]
+ public void BaseTypeChain_excludes_system_object()
+ {
+ // Widget extends Gadget which itself extends object. The chain must stop at
+ // the object boundary: observed 1 (not 2), so min:2 triggers a violation on
+ // Widget. Gadget has chain length 0 — also violates min:2.
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(min: 2)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Gadget;
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(2);
+ diagnostics.ShouldContain(d =>
+ d.GetMessage(CultureInfo.InvariantCulture).Contains("observed 1"));
+ diagnostics.ShouldContain(d =>
+ d.GetMessage(CultureInfo.InvariantCulture).Contains("observed 0"));
+ }
+
+ [Fact]
+ public void BaseTypeChain_walks_transitively_through_grandparent()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(min: 2)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Root;
+public record Gadget : Root;
+public record Widget : Gadget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ // Widget: chain = [Gadget, Root] → length 2 (pass)
+ // Gadget: chain = [Root] → length 1 (fail)
+ // Root: chain = [] → length 0 (fail)
+ diagnostics.Length.ShouldBe(2);
+ }
+
+ [Fact]
+ public void BaseTypeChain_custom_presence_spec_overrides_id_and_severity()
+ {
+ var shape = Stencil.ExposeRecord()
+ .BaseTypeChain(
+ min: 1,
+ presenceSpec: new DiagnosticSpec(Id: "CUST300", Severity: DiagnosticSeverity.Warning))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("CUST300");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ }
+
+ [Fact]
+ public void BaseTypeChain_negative_min_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().BaseTypeChain(min: -1));
+ }
+
+ [Fact]
+ public void BaseTypeChain_max_less_than_min_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().BaseTypeChain(min: 5, max: 3));
+ }
+
+ 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/CacheCorrectnessAnalyzerTests.cs b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs
index 5b56401..9cb7fa8 100644
--- a/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs
+++ b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs
@@ -13,7 +13,7 @@ namespace Scribe.Tests.Shapes;
///
/// Validates : every
-/// ShapeBuilder.Project<TModel> invocation with a TModel
+/// TypeShape.Etch<TModel> invocation with a TModel
/// that stores a Roslyn reference type (ISymbol, SyntaxNode,
/// Compilation, SemanticModel, SyntaxTree, Location,
/// AttributeData) should emit SCRIBE200 pointing at the offending member.
@@ -33,9 +33,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Bad(null!));
+ .Etch((in ShapeEtchContext ctx) => new Bad(null!));
}
}
";
@@ -64,9 +64,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Bad());
+ .Etch((in ShapeEtchContext ctx) => new Bad());
}
}
";
@@ -92,9 +92,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Bad(null!, null!));
+ .Etch((in ShapeEtchContext ctx) => new Bad(null!, null!));
}
}
";
@@ -119,9 +119,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Good(ctx.Fqn, ""x"", null));
+ .Etch((in ShapeEtchContext ctx) => new Good(ctx.Fqn, ""x"", null));
}
}
";
@@ -146,9 +146,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Model(ctx.Fqn));
+ .Etch((in ShapeEtchContext ctx) => new Model(ctx.Fqn));
}
}
";
@@ -170,9 +170,9 @@ public class Use
{
public void M()
{
- var _ = Shape.Class()
+ var _ = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Bad(null!));
+ .Etch((in ShapeEtchContext ctx) => new Bad(null!));
}
}
";
@@ -198,7 +198,7 @@ void AddIfMissing(System.Reflection.Assembly asm)
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.Shapes.Stencil).Assembly);
AddIfMissing(typeof(Scribe.Cache.LocationInfo).Assembly);
AddIfMissing(typeof(Microsoft.CodeAnalysis.ISymbol).Assembly);
var compilation = CSharpCompilation.Create(
diff --git a/Scribe.Tests/Shapes/ConstructorAndNamedArgLeafPredicatesTests.cs b/Scribe.Tests/Shapes/ConstructorAndNamedArgLeafPredicatesTests.cs
new file mode 100644
index 0000000..fddf29f
--- /dev/null
+++ b/Scribe.Tests/Shapes/ConstructorAndNamedArgLeafPredicatesTests.cs
@@ -0,0 +1,163 @@
+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;
+
+///
+/// Exercises and
+/// — MustBe and
+/// MustNotBeEmpty — on positional and named attribute arguments.
+///
+public class ConstructorAndNamedArgLeafPredicatesTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void ConstructorArg_MustBe_fires_on_mismatch()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, configure: a => a.MustBe("deprecated")))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"please don't\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE080");
+ }
+
+ [Fact]
+ public void ConstructorArg_MustBe_is_silent_on_match()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, configure: a => a.MustBe("deprecated")))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ConstructorArg_MustNotBeEmpty_fires_on_empty_string()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, configure: a => a.MustNotBeEmpty()))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE081");
+ }
+
+ [Fact]
+ public void ConstructorArg_MustNotBeEmpty_is_silent_on_non_empty_string()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, configure: a => a.MustNotBeEmpty()))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void NamedArg_MustBe_fires_on_mismatch()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.NamedArg(name: "Flag", configure: a => a.MustBe(true)))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { public bool Flag { get; set; } }
+[Thing(Flag = false)] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE085");
+ }
+
+ [Fact]
+ public void NamedArg_MustBe_is_silent_on_match()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("ThingAttribute", configure: attr =>
+ attr.NamedArg(name: "Flag", configure: a => a.MustBe(true)))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class ThingAttribute : System.Attribute { public bool Flag { get; set; } }
+[Thing(Flag = true)] public record Widget;
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void NamedArg_MustNotBeEmpty_fires_on_whitespace()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.NamedArg(name: "DiagnosticId", configure: a => a.MustNotBeEmpty()))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete(\"deprecated\", DiagnosticId = \" \")] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE086");
+ }
+
+ [Fact]
+ public void ConstructorArg_MustBe_null_shape_throws()
+ {
+ Should.Throw(() =>
+ ConstructorArgFocusLeafPredicates.MustBe(null!, "x"));
+ }
+
+ [Fact]
+ public void NamedArg_MustBe_null_shape_throws()
+ {
+ Should.Throw(() =>
+ NamedArgFocusLeafPredicates.MustBe(null!, true));
+ }
+
+ 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/FocusEqualityTests.cs b/Scribe.Tests/Shapes/FocusEqualityTests.cs
new file mode 100644
index 0000000..a99fd3d
--- /dev/null
+++ b/Scribe.Tests/Shapes/FocusEqualityTests.cs
@@ -0,0 +1,252 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Equality contract for every cache-safe focus type introduced by B.2.
+/// Each focus carries a Roslyn payload (symbol, attribute, typed-constant)
+/// that must be excluded from equality; identity comes from the stable
+/// breadcrumb (FQN + index/depth/name + Origin). These tests lock in that
+/// contract so the incremental pipeline can trust focus equality as its
+/// cache key.
+///
+public class FocusEqualityTests
+{
+ private static LocationInfo Loc(string path, int start) =>
+ new(path,
+ new TextSpan(start, 1),
+ new LinePositionSpan(new LinePosition(start, 0), new LinePosition(start, 1)));
+
+ // ---------- AttributeFocus ----------
+
+ [Fact]
+ public void AttributeFocus_equal_when_breadcrumbs_match()
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new AttributeFocus(null!, "N.Attr", "N.Owner", 0, origin);
+ var b = new AttributeFocus(null!, "N.Attr", "N.Owner", 0, origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ (a == b).ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData("N.Other", "N.Owner", 0)]
+ [InlineData("N.Attr", "N.Other", 0)]
+ [InlineData("N.Attr", "N.Owner", 1)]
+ public void AttributeFocus_not_equal_when_breadcrumb_differs(string fqn, string owner, int index)
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new AttributeFocus(null!, "N.Attr", "N.Owner", 0, origin);
+ var b = new AttributeFocus(null!, fqn, owner, index, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ (a != b).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AttributeFocus_not_equal_when_origin_differs()
+ {
+ var a = new AttributeFocus(null!, "N.Attr", "N.Owner", 0, Loc("a.cs", 1));
+ var b = new AttributeFocus(null!, "N.Attr", "N.Owner", 0, Loc("b.cs", 1));
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ // ---------- TypeArgFocus ----------
+
+ [Fact]
+ public void TypeArgFocus_equal_when_breadcrumbs_match()
+ {
+ var parent = Loc("p.cs", 1);
+ var origin = Loc("a.cs", 2);
+ var a = new TypeArgFocus(null!, "System.String", 0, parent, origin);
+ var b = new TypeArgFocus(null!, "System.String", 0, parent, origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void TypeArgFocus_ParentOrigin_disambiguates_same_fqn_and_index()
+ {
+ var origin = Loc("a.cs", 2);
+ var a = new TypeArgFocus(null!, "System.String", 0, Loc("p1.cs", 1), origin);
+ var b = new TypeArgFocus(null!, "System.String", 0, Loc("p2.cs", 1), origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData("System.Other", 0)]
+ [InlineData("System.String", 1)]
+ public void TypeArgFocus_not_equal_when_breadcrumb_differs(string fqn, int index)
+ {
+ var parent = Loc("p.cs", 1);
+ var origin = Loc("a.cs", 2);
+ var a = new TypeArgFocus(null!, "System.String", 0, parent, origin);
+ var b = new TypeArgFocus(null!, fqn, index, parent, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ // ---------- ConstructorArgFocus ----------
+
+ [Fact]
+ public void ConstructorArgFocus_equal_when_breadcrumbs_match()
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new ConstructorArgFocus(default, 42, 0, "N.Owner", origin);
+ var b = new ConstructorArgFocus(default, 42, 0, "N.Owner", origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void ConstructorArgFocus_not_equal_when_value_differs()
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new ConstructorArgFocus(default, 42, 0, "N.Owner", origin);
+ var b = new ConstructorArgFocus(default, 99, 0, "N.Owner", origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(1, "N.Owner")]
+ [InlineData(0, "N.Other")]
+ public void ConstructorArgFocus_not_equal_when_breadcrumb_differs(int index, string owner)
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new ConstructorArgFocus(default, 42, 0, "N.Owner", origin);
+ var b = new ConstructorArgFocus(default, 42, index, owner, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ConstructorArgFocus_supports_null_string_value()
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new ConstructorArgFocus(default, null, 0, "N.Owner", origin);
+ var b = new ConstructorArgFocus(default, null, 0, "N.Owner", origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ // ---------- NamedArgFocus ----------
+
+ [Fact]
+ public void NamedArgFocus_equal_when_breadcrumbs_match()
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new NamedArgFocus(default, "v", "Prop", "N.Owner", origin);
+ var b = new NamedArgFocus(default, "v", "Prop", "N.Owner", origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Theory]
+ [InlineData("w", "Prop", "N.Owner")]
+ [InlineData("v", "Other", "N.Owner")]
+ [InlineData("v", "Prop", "N.Other")]
+ public void NamedArgFocus_not_equal_when_breadcrumb_differs(string value, string name, string owner)
+ {
+ var origin = Loc("a.cs", 1);
+ var a = new NamedArgFocus(default, "v", "Prop", "N.Owner", origin);
+ var b = new NamedArgFocus(default, value, name, owner, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ // ---------- BaseTypeChainFocus ----------
+
+ [Fact]
+ public void BaseTypeChainFocus_equal_when_breadcrumbs_match()
+ {
+ var root = Loc("r.cs", 1);
+ var origin = Loc("b.cs", 2);
+ var a = new BaseTypeChainFocus(null!, "N.Base", 0, root, origin);
+ var b = new BaseTypeChainFocus(null!, "N.Base", 0, root, origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Theory]
+ [InlineData("N.Other", 0)]
+ [InlineData("N.Base", 1)]
+ public void BaseTypeChainFocus_not_equal_when_breadcrumb_differs(string fqn, int depth)
+ {
+ var root = Loc("r.cs", 1);
+ var origin = Loc("b.cs", 2);
+ var a = new BaseTypeChainFocus(null!, "N.Base", 0, root, origin);
+ var b = new BaseTypeChainFocus(null!, fqn, depth, root, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void BaseTypeChainFocus_RootOrigin_disambiguates_same_step_from_different_roots()
+ {
+ var origin = Loc("b.cs", 2);
+ var a = new BaseTypeChainFocus(null!, "N.Base", 0, Loc("r1.cs", 1), origin);
+ var b = new BaseTypeChainFocus(null!, "N.Base", 0, Loc("r2.cs", 1), origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ // ---------- MemberFocus ----------
+
+ [Fact]
+ public void MemberFocus_equal_when_breadcrumbs_match()
+ {
+ var origin = Loc("m.cs", 1);
+ var a = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, origin);
+ var b = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Fact]
+ public void MemberFocus_Kind_is_not_part_of_equality()
+ {
+ var origin = Loc("m.cs", 1);
+ var a = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, origin);
+ var b = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Property, origin);
+
+ a.Equals(b).ShouldBeTrue();
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+
+ [Theory]
+ [InlineData("N.T.Other", "N.T")]
+ [InlineData("N.T.M", "N.Other")]
+ public void MemberFocus_not_equal_when_breadcrumb_differs(string member, string owner)
+ {
+ var origin = Loc("m.cs", 1);
+ var a = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, origin);
+ var b = new MemberFocus(null!, member, owner, SymbolKind.Method, origin);
+
+ a.Equals(b).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void MemberFocus_not_equal_when_origin_differs()
+ {
+ var a = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, Loc("a.cs", 1));
+ var b = new MemberFocus(null!, "N.T.M", "N.T", SymbolKind.Method, Loc("b.cs", 1));
+
+ a.Equals(b).ShouldBeFalse();
+ }
+}
diff --git a/Scribe.Tests/Shapes/FocusPathTests.cs b/Scribe.Tests/Shapes/FocusPathTests.cs
new file mode 100644
index 0000000..00264e3
--- /dev/null
+++ b/Scribe.Tests/Shapes/FocusPathTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// B.8 — verifies that lens branches stamp a human-readable focus-path
+/// breadcrumb onto every diagnostic they produce, so readers can tell which
+/// navigation hops led to a violation (e.g. [Attributes("Foo")] ...).
+///
+public class FocusPathTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Top_level_check_has_no_focus_path_prefix()
+ {
+ var shape = Stencil.ExposeClass()
+ .MustBePartial()
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldNotStartWith("[");
+ }
+
+ [Fact]
+ public void Attributes_presence_violation_includes_attributes_hop_in_path()
+ {
+ var shape = Stencil.ExposeClass()
+ .Attributes("System.ObsoleteAttribute", min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ var message = diagnostics[0].GetMessage(CultureInfo.InvariantCulture);
+ message.ShouldStartWith("[Attributes(\"System.ObsoleteAttribute\")]");
+ }
+
+ [Fact]
+ public void Nested_sub_lens_violation_includes_full_hop_chain()
+ {
+ // Attributes → GenericTypeArg(0, min=1) — class has no generic arg, so
+ // the inner presence check fires. Path should include both hops.
+ var shape = Stencil.ExposeRecord()
+ .Attributes("System.ObsoleteAttribute", configure: attr =>
+ attr.ConstructorArg(index: 0, min: 1))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "[System.Obsolete] public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ var message = diagnostics[0].GetMessage(CultureInfo.InvariantCulture);
+ message.ShouldContain("Attributes(\"System.ObsoleteAttribute\")");
+ }
+
+ 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/FocusSymbolsTests.cs b/Scribe.Tests/Shapes/FocusSymbolsTests.cs
new file mode 100644
index 0000000..1fa0663
--- /dev/null
+++ b/Scribe.Tests/Shapes/FocusSymbolsTests.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// B.7 — exercises cross-focus symbol comparison
+/// helpers directly. Callers wire these into Satisfy predicates or
+/// lens-configure callbacks to assert cross-focus identity (e.g. "the
+/// navigated type argument equals the outer type being matched").
+///
+public class FocusSymbolsTests
+{
+ [Fact]
+ public void SymbolEquals_returns_true_for_same_symbol_reference()
+ {
+ var (widget, _) = CompileAndGetTypes();
+ FocusSymbols.SymbolEquals(widget, widget).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SymbolEquals_returns_false_for_different_types()
+ {
+ var (widget, other) = CompileAndGetTypes();
+ FocusSymbols.SymbolEquals(widget, other).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SymbolEquals_returns_false_when_either_side_is_null()
+ {
+ var (widget, _) = CompileAndGetTypes();
+ FocusSymbols.SymbolEquals(null, widget).ShouldBeFalse();
+ FocusSymbols.SymbolEquals(widget, null).ShouldBeFalse();
+ FocusSymbols.SymbolEquals(null, null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SameOriginalDefinition_matches_closed_generic_against_open_definition()
+ {
+ // List and List share an OriginalDefinition but differ under SymbolEquals.
+ var source = @"
+using System.Collections.Generic;
+public class Widget {
+ public List A;
+ public List B;
+}
+";
+ var compilation = Compile(source);
+ var widget = compilation.GetTypeByMetadataName("Widget")!;
+ var listOfInt = ((IFieldSymbol)widget.GetMembers("A").Single()).Type;
+ var listOfString = ((IFieldSymbol)widget.GetMembers("B").Single()).Type;
+
+ FocusSymbols.SymbolEquals(listOfInt, listOfString).ShouldBeFalse();
+ FocusSymbols.SameOriginalDefinition(listOfInt, listOfString).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SameOriginalDefinition_returns_false_for_unrelated_types()
+ {
+ var (widget, other) = CompileAndGetTypes();
+ FocusSymbols.SameOriginalDefinition(widget, other).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void SameOriginalDefinition_returns_false_when_either_side_is_null()
+ {
+ var (widget, _) = CompileAndGetTypes();
+ FocusSymbols.SameOriginalDefinition(null, widget).ShouldBeFalse();
+ FocusSymbols.SameOriginalDefinition(widget, null).ShouldBeFalse();
+ FocusSymbols.SameOriginalDefinition(null, null).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TypeFocus_extension_compares_by_original_definition()
+ {
+ var (widget, other) = CompileAndGetTypes();
+ var a = new TypeFocus(widget, widget.ToDisplayString(), origin: null);
+ var b = new TypeFocus(widget, widget.ToDisplayString(), origin: null);
+ var c = new TypeFocus(other, other.ToDisplayString(), origin: null);
+
+ a.SameOriginalDefinition(b).ShouldBeTrue();
+ a.SameOriginalDefinition(c).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TypeArgFocus_extension_compares_against_TypeFocus()
+ {
+ var (widget, _) = CompileAndGetTypes();
+ var typeFocus = new TypeFocus(widget, widget.ToDisplayString(), origin: null);
+ var matchingArg = new TypeArgFocus(widget, widget.ToDisplayString(), index: 0, parentOrigin: null, origin: null);
+
+ typeFocus.SameOriginalDefinition(matchingArg).ShouldBeTrue();
+ matchingArg.SameOriginalDefinition(typeFocus).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void TypeArgFocus_extension_returns_false_for_mismatched_arg()
+ {
+ var (widget, other) = CompileAndGetTypes();
+ var typeFocus = new TypeFocus(widget, widget.ToDisplayString(), origin: null);
+ var wrongArg = new TypeArgFocus(other, other.ToDisplayString(), index: 0, parentOrigin: null, origin: null);
+
+ typeFocus.SameOriginalDefinition(wrongArg).ShouldBeFalse();
+ }
+
+ private static (INamedTypeSymbol Widget, INamedTypeSymbol Other) CompileAndGetTypes()
+ {
+ var compilation = Compile("public class Widget { } public class Other { }");
+ return (
+ compilation.GetTypeByMetadataName("Widget")!,
+ compilation.GetTypeByMetadataName("Other")!);
+ }
+
+ 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/LensTests.cs b/Scribe.Tests/Shapes/LensTests.cs
new file mode 100644
index 0000000..60f1c27
--- /dev/null
+++ b/Scribe.Tests/Shapes/LensTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Cache;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Exercises — the structural refocus
+/// primitive that every built-in lens will be an instance of. Uses
+/// lightweight equatable record structs in place of real Roslyn focus rows
+/// so the tests assert the primitive's contract (Navigate, Smudge, Then)
+/// without depending on B.2 focus types.
+///
+public class LensTests
+{
+ private readonly record struct Parent(string Name, IReadOnlyList Children);
+
+ private readonly record struct Child(string Name);
+
+ private readonly record struct Grand(string Name);
+
+ [Fact]
+ public void Navigate_yields_every_target_for_a_given_source()
+ {
+ var lens = new Lens(
+ navigate: p => p.Children,
+ smudge: _ => null);
+
+ var parent = new Parent("p", [new Child("a"), new Child("b")]);
+
+ var children = lens.Navigate(parent).ToArray();
+
+ children.ShouldBe([new Child("a"), new Child("b")]);
+ }
+
+ [Fact]
+ public void Navigate_returning_empty_enumerable_means_lens_does_not_apply()
+ {
+ var lens = new Lens(
+ navigate: _ => [],
+ smudge: _ => null);
+
+ lens.Navigate(new Parent("p", [])).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Smudge_resolves_the_target_focus_source_span()
+ {
+ var location = new LocationInfo(
+ "test.cs",
+ new TextSpan(100, 10),
+ new LinePositionSpan(new LinePosition(5, 10), new LinePosition(5, 20)));
+ var lens = new Lens(
+ navigate: p => p.Children,
+ smudge: _ => location);
+
+ lens.Smudge(new Child("a")).ShouldBe(location);
+ }
+
+ [Fact]
+ public void Then_composes_two_lenses_into_a_deeper_hop()
+ {
+ var parentToChild = new Lens(
+ navigate: p => p.Children,
+ smudge: _ => null);
+ var childToGrand = new Lens(
+ navigate: c => [new Grand(c.Name + "-1"), new Grand(c.Name + "-2")],
+ smudge: _ => null);
+
+ var composed = parentToChild.Then(childToGrand);
+
+ var parent = new Parent("p", [new Child("a"), new Child("b")]);
+ var grandchildren = composed.Navigate(parent).ToArray();
+
+ grandchildren.ShouldBe([
+ new Grand("a-1"),
+ new Grand("a-2"),
+ new Grand("b-1"),
+ new Grand("b-2"),
+ ]);
+ }
+
+ [Fact]
+ public void Then_forwards_smudge_to_the_deepest_target()
+ {
+ var childLocation = new LocationInfo(
+ "child.cs",
+ new TextSpan(0, 5),
+ new LinePositionSpan(new LinePosition(1, 0), new LinePosition(1, 5)));
+ var grandLocation = new LocationInfo(
+ "grand.cs",
+ new TextSpan(10, 5),
+ new LinePositionSpan(new LinePosition(2, 0), new LinePosition(2, 5)));
+
+ var parentToChild = new Lens(
+ navigate: p => p.Children,
+ smudge: _ => childLocation);
+ var childToGrand = new Lens(
+ navigate: c => [new Grand(c.Name)],
+ smudge: _ => grandLocation);
+
+ var composed = parentToChild.Then(childToGrand);
+
+ composed.Smudge(new Grand("x")).ShouldBe(grandLocation);
+ }
+
+ [Fact]
+ public void Constructor_rejects_null_navigate()
+ {
+ Should.Throw(
+ () => new Lens(navigate: null!, smudge: _ => null));
+ }
+
+ [Fact]
+ public void Constructor_rejects_null_smudge()
+ {
+ Should.Throw(
+ () => new Lens(navigate: p => p.Children, smudge: null!));
+ }
+
+ [Fact]
+ public void Then_rejects_null_next_lens()
+ {
+ var lens = new Lens(
+ navigate: p => p.Children,
+ smudge: _ => null);
+
+ Should.Throw(() => lens.Then(null!));
+ }
+}
diff --git a/Scribe.Tests/Shapes/MemberFocusLeafPredicatesTests.cs b/Scribe.Tests/Shapes/MemberFocusLeafPredicatesTests.cs
new file mode 100644
index 0000000..527162c
--- /dev/null
+++ b/Scribe.Tests/Shapes/MemberFocusLeafPredicatesTests.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises the v1 leaf predicates on —
+/// MustHaveAttribute, MustBePublic, MustBeStatic,
+/// MustBeReadOnly — reached via the Members(...) lens.
+///
+public class MemberFocusLeafPredicatesTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void MustBePublic_fires_when_private_member_present()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Field, configure: m => m.MustBePublic())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ private int _count;
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE061");
+ }
+
+ [Fact]
+ public void MustBePublic_is_silent_when_every_member_is_public()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m => m.MustBePublic())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustBeReadOnly_fires_on_mutable_property()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m => m.MustBeReadOnly())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE063");
+ }
+
+ [Fact]
+ public void MustBeReadOnly_is_silent_on_init_only_property()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m => m.MustBeReadOnly())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustBeReadOnly_is_silent_on_readonly_field()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Field, configure: m => m.MustBeReadOnly())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public readonly int Count;
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustBeStatic_fires_on_instance_field()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Field, configure: m => m.MustBeStatic())
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count;
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE062");
+ }
+
+ [Fact]
+ public void MustHaveAttribute_fires_when_member_missing_attribute()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.MustHaveAttribute("System.ObsoleteAttribute"))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "SCRIBE060");
+ diagnostics.First(d => d.Id == "SCRIBE060")
+ .GetMessage(CultureInfo.InvariantCulture).ShouldContain("Count");
+ }
+
+ [Fact]
+ public void MustHaveAttribute_is_silent_when_member_has_attribute()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.MustHaveAttribute("System.ObsoleteAttribute"))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ [System.Obsolete]
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustBePublic_null_shape_throws()
+ {
+ Should.Throw(() =>
+ MemberFocusLeafPredicates.MustBePublic(null!));
+ }
+
+ 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/MembersLensTests.cs b/Scribe.Tests/Shapes/MembersLensTests.cs
new file mode 100644
index 0000000..37edcad
--- /dev/null
+++ b/Scribe.Tests/Shapes/MembersLensTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+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;
+
+///
+/// Exercises TypeShape.Members(kind?, configure, min, max) end-to-end against
+/// the analyzer materialisation path. Validates kind filtering, source-order
+/// navigation, and presence-constraint diagnostics.
+///
+public class MembersLensTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Members_min1_emits_presence_violation_when_type_has_no_declared_members()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE051");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 0");
+ }
+
+ [Fact]
+ public void Members_min1_is_silent_when_type_has_declared_member()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { public int Size; }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Members_kind_filter_only_counts_matching_members()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(kind: SymbolKind.Property, min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // Widget has a field but no property — should violate the Property min:1 constraint.
+ var source = "public class Widget { public int Size; }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE051");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 0");
+ }
+
+ [Fact]
+ public void Members_kind_filter_silent_when_matching_member_present()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(kind: SymbolKind.Property, min: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { public int Size { get; set; } }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Members_max_violation_fires_when_count_exceeds_max()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(kind: SymbolKind.Field, max: 1)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { public int A; public int B; public int C; }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE051");
+ diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("observed 3");
+ }
+
+ [Fact]
+ public void Members_custom_presence_spec_overrides_id_and_severity()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ min: 1,
+ presenceSpec: new DiagnosticSpec(Id: "CUST200", Severity: DiagnosticSeverity.Info))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("CUST200");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Info);
+ }
+
+ [Fact]
+ public void Members_negative_min_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().Members(min: -1));
+ }
+
+ [Fact]
+ public void Members_max_less_than_min_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().Members(min: 5, max: 3));
+ }
+
+ 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/OneOfTests.cs b/Scribe.Tests/Shapes/OneOfTests.cs
new file mode 100644
index 0000000..ff53280
--- /dev/null
+++ b/Scribe.Tests/Shapes/OneOfTests.cs
@@ -0,0 +1,196 @@
+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;
+
+///
+/// Exercises — B.5 disjunction composition.
+/// Verifies that a symbol passes when at least one alternative is silent
+/// against it, that a fused diagnostic summarises every branch's unsatisfied
+/// checks when no alternative passes, and that kind-mismatched branches
+/// cannot accidentally satisfy a symbol.
+///
+public class OneOfTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Stencil_OneOf_passes_when_first_alternative_is_silent()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly().MustBePartial();
+ var abstractRecord = Stencil.ExposeRecord().MustBeAbstract().MustBePartial();
+
+ var shape = Stencil.OneOf(readonlyStruct, abstractRecord)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public readonly partial record struct Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Stencil_OneOf_passes_when_second_alternative_is_silent()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly().MustBePartial();
+ var abstractRecord = Stencil.ExposeRecord().MustBeAbstract().MustBePartial();
+
+ var shape = Stencil.OneOf(readonlyStruct, abstractRecord)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public abstract partial record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Stencil_OneOf_fires_fused_diagnostic_when_no_alternative_passes()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly().MustBePartial();
+ var abstractRecord = Stencil.ExposeRecord().MustBeAbstract().MustBePartial();
+
+ var shape = Stencil.OneOf(readonlyStruct, abstractRecord)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // A mutable, non-partial record — neither alternative can satisfy.
+ var source = "public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE100");
+ var msg = diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture);
+ msg.ShouldContain("branch1");
+ msg.ShouldContain("branch2");
+ msg.ShouldContain("—OR—");
+ }
+
+ [Fact]
+ public void TypeShape_OneOf_instance_method_composes_with_sibling()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly().MustBePartial();
+ var abstractRecord = Stencil.ExposeRecord().MustBeAbstract().MustBePartial();
+
+ var shape = readonlyStruct.OneOf(abstractRecord)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public readonly partial record struct Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void OneOf_three_branch_fusion_lists_all_branch_ids()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly();
+ var abstractClass = Stencil.ExposeClass().MustBeAbstract().MustBePartial();
+ var sealedClass = Stencil.ExposeClass().MustBeSealed();
+
+ var shape = Stencil.OneOf(readonlyStruct, abstractClass, sealedClass)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // A plain non-sealed, non-abstract class — neither of the three branches passes.
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE100");
+ var msg = diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture);
+ msg.ShouldContain("branch1");
+ msg.ShouldContain("branch2");
+ msg.ShouldContain("branch3");
+ }
+
+ [Fact]
+ public void OneOf_custom_fusion_spec_overrides_id_and_severity()
+ {
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly();
+ var abstractRecord = Stencil.ExposeRecord().MustBeAbstract();
+
+ var shape = Stencil.OneOf(
+ fusionSpec: new DiagnosticSpec(
+ Id: "CUST800",
+ Severity: DiagnosticSeverity.Warning,
+ Message: "Expected [{0}] alternatives; none matched: {1}"),
+ alternatives: new[] { readonlyStruct, abstractRecord })
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public record Widget;";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("CUST800");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ }
+
+ [Fact]
+ public void OneOf_kind_mismatched_branch_does_not_accidentally_pass()
+ {
+ // Branch 1 is for record-structs; branch 2 for classes. A class should
+ // be evaluated by branch 2 only — if branch 1 were silently treated as
+ // "irrelevant => passes", the fusion wouldn't fire.
+ var readonlyStruct = Stencil.ExposeRecordStruct().MustBeReadOnly();
+ var abstractClass = Stencil.ExposeClass().MustBeAbstract();
+
+ var shape = Stencil.OneOf(readonlyStruct, abstractClass)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // Plain class — branch 2 fails MustBeAbstract; branch 1 is kind-mismatched.
+ var source = "public class Widget { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE100");
+ }
+
+ [Fact]
+ public void OneOf_requires_at_least_two_alternatives()
+ {
+ Should.Throw(() =>
+ Stencil.OneOf(Stencil.ExposeClass()));
+ }
+
+ [Fact]
+ public void OneOf_rejects_alternatives_with_differing_primary_attribute()
+ {
+ var left = Stencil.ExposeClass().MustHaveAttribute("System.ObsoleteAttribute");
+ var right = Stencil.ExposeClass();
+
+ // Shapes with asymmetric primary-attribute drivers aren't fusable —
+ // ForAttributeWithMetadataName can only target one name per analyzer pass.
+ // v1 enforces this at authoring time rather than silently losing branches.
+ Should.Throw(() => Stencil.OneOf(left, right));
+ }
+
+ 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/RelationPairIncrementalityTests.cs b/Scribe.Tests/Shapes/PrismIncrementalityTests.cs
similarity index 90%
rename from Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs
rename to Scribe.Tests/Shapes/PrismIncrementalityTests.cs
index 6f6faf7..f76baae 100644
--- a/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs
+++ b/Scribe.Tests/Shapes/PrismIncrementalityTests.cs
@@ -11,13 +11,13 @@
namespace Scribe.Tests.Shapes;
///
-/// Verifies cache correctness of by
+/// 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
+/// the matched step to re-execute — otherwise the whole point of projecting
/// into cache-safe models is wasted.
///
-public class RelationPairIncrementalityTests
+public class PrismIncrementalityTests
{
private const string MatchedTrackingName = "scribe-test-matched";
@@ -25,27 +25,27 @@ public class RelationPairIncrementalityTests
private readonly record struct Event(string Fqn, string Name);
- private sealed class PairGenerator : IIncrementalGenerator
+ private sealed class PrismGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
- var commandShape = Shape.Class()
+ var commandShape = Stencil.ExposeClass()
.MustHaveAttribute("CommandAttribute")
- .Project((in ShapeProjectionContext ctx) =>
+ .Etch((in ShapeEtchContext ctx) =>
new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty));
- var eventShape = Shape.Class()
+ var eventShape = Stencil.ExposeClass()
.MustHaveAttribute("EventAttribute")
- .Project((in ShapeProjectionContext ctx) =>
+ .Etch((in ShapeEtchContext ctx) =>
new Event(ctx.Fqn, ctx.Fqn));
- var pair = Relation.Pair(
+ var prism = Prism.By(
commandShape.ToProvider(context),
eventShape.ToProvider(context),
c => c.RaisesEventName,
e => e.Name);
- var tracked = pair.Matched.WithTrackingName(MatchedTrackingName);
+ var tracked = prism.Matched.WithTrackingName(MatchedTrackingName);
context.RegisterSourceOutput(tracked, (_, _) => { });
}
}
@@ -63,7 +63,7 @@ private static CSharpCompilation MakeCompilation(string source) =>
private static CSharpGeneratorDriver MakeDriver() =>
CSharpGeneratorDriver.Create(
- generators: [new PairGenerator().AsSourceGenerator()],
+ generators: [new PrismGenerator().AsSourceGenerator()],
additionalTexts: default,
parseOptions: null,
optionsProvider: null,
diff --git a/Scribe.Tests/Shapes/RelationPairTests.cs b/Scribe.Tests/Shapes/PrismTests.cs
similarity index 80%
rename from Scribe.Tests/Shapes/RelationPairTests.cs
rename to Scribe.Tests/Shapes/PrismTests.cs
index 5eddc8f..e21eb35 100644
--- a/Scribe.Tests/Shapes/RelationPairTests.cs
+++ b/Scribe.Tests/Shapes/PrismTests.cs
@@ -11,39 +11,39 @@
namespace Scribe.Tests.Shapes;
///
-/// Drives through
+/// 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
+/// matched stream, orphan-left diagnostic, orphan-right diagnostic.
+/// Grounding scenario is a generic 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.
+/// the prism joins on that shared key.
///
-public class RelationPairTests
+public class PrismTests
{
private readonly record struct Command(string Fqn, string RaisesEventName);
private readonly record struct Event(string Fqn, string Name);
- private sealed class PairGenerator : IIncrementalGenerator
+ private sealed class PrismGenerator : IIncrementalGenerator
{
- public static readonly List> Pairs = new();
+ public static readonly List> Pairs = new();
public static readonly List Diagnostics = new();
public void Initialize(IncrementalGeneratorInitializationContext context)
{
- var commandShape = Shape.Class()
+ var commandShape = Stencil.ExposeClass()
.MustHaveAttribute("CommandAttribute")
- .Project((in ShapeProjectionContext ctx) =>
+ .Etch((in ShapeEtchContext ctx) =>
new Command(
Fqn: ctx.Fqn,
RaisesEventName: ctx.Attribute.Ctor(0) ?? string.Empty));
- var eventShape = Shape.Class()
+ var eventShape = Stencil.ExposeClass()
.MustHaveAttribute("EventAttribute")
- .Project((in ShapeProjectionContext ctx) =>
+ .Etch((in ShapeEtchContext ctx) =>
new Event(Fqn: ctx.Fqn, Name: ctx.Fqn));
- var pair = Relation.Pair(
+ var prism = Prism.By(
commandShape.ToProvider(context),
eventShape.ToProvider(context),
leftKey: c => c.RaisesEventName,
@@ -57,12 +57,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
title: "Event is declared but never raised",
messageFormat: "Event '{0}' is declared but never raised");
- context.RegisterSourceOutput(pair.Matched, (spc, p) =>
+ context.RegisterSourceOutput(prism.Matched, (spc, p) =>
{
lock (Pairs) { Pairs.Add(p); }
});
- pair.RegisterDiagnostics(context);
+ prism.RegisterDiagnostics(context);
}
}
@@ -77,13 +77,13 @@ private static CSharpCompilation MakeCompilation(string source) =>
],
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
- private static (Microsoft.CodeAnalysis.GeneratorDriverRunResult Result, List> Pairs) Run(string source)
+ private static (Microsoft.CodeAnalysis.GeneratorDriverRunResult Result, List> Pairs) Run(string source)
{
- PairGenerator.Pairs.Clear();
- PairGenerator.Diagnostics.Clear();
- var driver = CSharpGeneratorDriver.Create(new PairGenerator())
+ PrismGenerator.Pairs.Clear();
+ PrismGenerator.Diagnostics.Clear();
+ var driver = CSharpGeneratorDriver.Create(new PrismGenerator())
.RunGenerators(MakeCompilation(source), TestContext.Current.CancellationToken);
- return (driver.GetRunResult(), [..PairGenerator.Pairs]);
+ return (driver.GetRunResult(), [..PrismGenerator.Pairs]);
}
[Fact]
@@ -161,19 +161,19 @@ public void Silent_default_emits_no_diagnostics_without_policy()
{
var silentGen = new InlineGen((context) =>
{
- var left = Shape.Class()
+ var left = Stencil.ExposeClass()
.MustHaveAttribute("CommandAttribute")
- .Project((in ShapeProjectionContext ctx) =>
+ .Etch((in ShapeEtchContext ctx) =>
new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty));
- var right = Shape.Class()
+ var right = Stencil.ExposeClass()
.MustHaveAttribute("EventAttribute")
- .Project((in ShapeProjectionContext ctx) => new Event(ctx.Fqn, ctx.Fqn));
+ .Etch((in ShapeEtchContext ctx) => new Event(ctx.Fqn, ctx.Fqn));
- var pair = Relation.Pair(left.ToProvider(context), right.ToProvider(context),
+ var prism = Prism.By(left.ToProvider(context), right.ToProvider(context),
c => c.RaisesEventName, e => e.Name);
- pair.RegisterDiagnostics(context);
- context.RegisterSourceOutput(pair.Matched, (spc, _) => { });
+ prism.RegisterDiagnostics(context);
+ context.RegisterSourceOutput(prism.Matched, (spc, _) => { });
});
var driver = CSharpGeneratorDriver.Create(silentGen).RunGenerators(
diff --git a/Scribe.Tests/Shapes/QuantifierTests.cs b/Scribe.Tests/Shapes/QuantifierTests.cs
new file mode 100644
index 0000000..1417c23
--- /dev/null
+++ b/Scribe.Tests/Shapes/QuantifierTests.cs
@@ -0,0 +1,279 @@
+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;
+
+///
+/// Exercises the B.6 set-level quantifiers on lens branches —
+/// (at least one child must pass) and (no child may pass) —
+/// across Members, Attributes, and BaseTypeChain entry points.
+///
+public class QuantifierTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Members_Any_is_silent_when_at_least_one_member_passes()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustBePublic(),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Widget
+{
+ private int _hidden { get; set; }
+ public int Visible { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Members_Any_emits_one_aggregate_when_no_member_passes()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustBePublic(),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Widget
+{
+ private int A { get; set; }
+ private int B { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE090");
+ }
+
+ [Fact]
+ public void Members_None_is_silent_when_no_member_matches()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustBePublic(),
+ quantifier: Quantifier.None)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ // None property is public — None quantifier requires every child to FAIL `MustBePublic`,
+ // i.e. no public property exists.
+ var source = @"
+public class Widget
+{
+ private int A { get; set; }
+ private int B { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Members_None_fires_per_offender_when_one_child_passes()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustBePublic(),
+ quantifier: Quantifier.None)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Widget
+{
+ public int A { get; set; }
+ public int B { get; set; }
+ private int C { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(2);
+ diagnostics.ShouldAllBe(d => d.Id == "SCRIBE091");
+ }
+
+ [Fact]
+ public void Attributes_Any_silent_when_one_application_passes()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes(
+ "Ns.ThingAttribute",
+ configure: a => a.ConstructorArg(0, p => p.MustBe("ok")),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+namespace Ns
+{
+ [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)]
+ public class ThingAttribute : System.Attribute
+ {
+ public ThingAttribute(string value) { }
+ }
+
+ [Thing(""no"")]
+ [Thing(""ok"")]
+ public record Widget;
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Attributes_Any_fires_aggregate_when_no_application_passes()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Attributes(
+ "Ns.ThingAttribute",
+ configure: a => a.ConstructorArg(0, p => p.MustBe("ok")),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+namespace Ns
+{
+ [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)]
+ public class ThingAttribute : System.Attribute
+ {
+ public ThingAttribute(string value) { }
+ }
+
+ [Thing(""no"")]
+ [Thing(""nope"")]
+ public record Widget;
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE092");
+ }
+
+ [Fact]
+ public void BaseTypeChain_Any_silent_when_chain_step_passes()
+ {
+ var shape = Stencil.ExposeClass()
+ .BaseTypeChain(
+ configure: b => b.AsTypeShape(t => t.MustBeNamed("Exception")),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget : System.Exception { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void BaseTypeChain_Any_fires_aggregate_when_no_step_matches()
+ {
+ var shape = Stencil.ExposeClass()
+ .BaseTypeChain(
+ configure: b => b.AsTypeShape(t => t.MustBeNamed("Missing")),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget : System.Exception { }";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Any(d => d.Id == "SCRIBE094").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Custom_quantifierSpec_overrides_id_and_severity()
+ {
+ var shape = Stencil.ExposeClass()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustBePublic(),
+ quantifier: Quantifier.Any,
+ quantifierSpec: new DiagnosticSpec(
+ Id: "CUST700",
+ Severity: DiagnosticSeverity.Warning,
+ Message: "Need at least one public property"))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Widget
+{
+ private int A { get; set; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("CUST700");
+ diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning);
+ }
+
+ [Fact]
+ public void Any_folds_leaf_predicate_result_into_pass_fail()
+ {
+ // Child "passes" only when every nested leaf predicate returns true. Any
+ // succeeds as soon as one property carries the required attribute.
+ var shape = Stencil.ExposeRecord()
+ .Members(
+ kind: SymbolKind.Property,
+ configure: m => m.MustHaveAttribute("System.ObsoleteAttribute"),
+ quantifier: Quantifier.Any)
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ [System.Obsolete]
+ public int Tagged { get; init; }
+ public int Untagged { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ 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/QueryComprehensionTests.cs b/Scribe.Tests/Shapes/QueryComprehensionTests.cs
new file mode 100644
index 0000000..322226c
--- /dev/null
+++ b/Scribe.Tests/Shapes/QueryComprehensionTests.cs
@@ -0,0 +1,136 @@
+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;
+
+///
+/// Exercises B.9 — LINQ query-comprehension parity. Verifies that a Shape
+/// authored with from / where / select produces the same analyzer
+/// behaviour as the equivalent fluent-form chain.
+///
+public class QueryComprehensionTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Select_aliases_Etch_and_surfaces_fqn()
+ {
+ Shape shape =
+ from t in Stencil.ExposeClass().MustBePartial()
+ select new Collected(t.Fqn);
+
+ var diagnostics = RunAnalyzer(shape, "public partial class Widget { }");
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Select_reports_underlying_check_violations()
+ {
+ Shape shape =
+ from t in Stencil.ExposeClass().MustBePartial()
+ select new Collected(t.Fqn);
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE001");
+ }
+
+ [Fact]
+ public void Query_form_with_where_clause_desugars_and_fires()
+ {
+ // `from t in ... where select ...` desugars to
+ // `source.Where(t => ).Select(t => ...)`.
+ Shape shape =
+ from t in Stencil.ExposeClass()
+ where t.Symbol.Name.StartsWith("Widget", StringComparison.Ordinal)
+ select new Collected(t.Fqn);
+
+ var passing = RunAnalyzer(shape, "public class Widget { }");
+ passing.ShouldBeEmpty();
+
+ var failing = RunAnalyzer(shape, "public class Gadget { }");
+ failing.Length.ShouldBe(1);
+ failing[0].Id.ShouldBe("SCRIBE200");
+ }
+
+ [Fact]
+ public void Where_explicit_spec_pins_custom_id()
+ {
+ var shape = Stencil.ExposeClass()
+ .Where(
+ t => t.Symbol.Name.StartsWith("Widget", StringComparison.Ordinal),
+ new DiagnosticSpec(Id: "WQ100", Message: "Type '{0}' must start with 'Widget'"))
+ .Select(t => new Collected(t.Fqn));
+
+ var failing = RunAnalyzer(shape, "public class Gadget { }");
+ failing.Length.ShouldBe(1);
+ failing[0].Id.ShouldBe("WQ100");
+ }
+
+ [Fact]
+ public void Fluent_and_query_forms_are_equivalent()
+ {
+ var query =
+ from t in Stencil.ExposeClass().MustBePartial()
+ where t.Fqn.Length > 0
+ select new Collected(t.Fqn);
+
+ var fluent = Stencil.ExposeClass()
+ .MustBePartial()
+ .Where(t => t.Fqn.Length > 0)
+ .Select(t => new Collected(t.Fqn));
+
+ var a = RunAnalyzer(query, "public class Widget { }");
+ var b = RunAnalyzer(fluent, "public class Widget { }");
+
+ a.Length.ShouldBe(b.Length);
+ a.Select(d => d.Id).OrderBy(id => id).ShouldBe(b.Select(d => d.Id).OrderBy(id => id));
+ }
+
+ [Fact]
+ public void Where_explicit_overload_requires_non_empty_id()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().Where(_ => true, new DiagnosticSpec()));
+ }
+
+ [Fact]
+ public void Select_rejects_null_selector()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeClass().Select(null!));
+ }
+
+ 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/SatisfyEscapeHatchTests.cs b/Scribe.Tests/Shapes/SatisfyEscapeHatchTests.cs
new file mode 100644
index 0000000..f9b5fb3
--- /dev/null
+++ b/Scribe.Tests/Shapes/SatisfyEscapeHatchTests.cs
@@ -0,0 +1,134 @@
+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;
+
+///
+/// Exercises the generic Satisfy escape hatch surfaced on every
+/// — see .
+///
+public class SatisfyEscapeHatchTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void Satisfy_fires_custom_diagnostic_when_member_predicate_false()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.Satisfy(
+ predicate: focus => focus.Symbol.Name.StartsWith('_'),
+ id: "CUSTOM001",
+ title: "Property must start with underscore",
+ message: "Property does not start with underscore",
+ severity: DiagnosticSeverity.Warning))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "CUSTOM001");
+ }
+
+ [Fact]
+ public void Satisfy_is_silent_when_predicate_true()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.Satisfy(
+ predicate: static focus => focus.Symbol.DeclaredAccessibility == Accessibility.Public,
+ id: "CUSTOM002",
+ title: "Public-only",
+ message: "nope"))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Satisfy_with_compilation_overload_can_access_semantic_model()
+ {
+ var shape = Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.Satisfy(
+ predicate: static (focus, compilation, _) =>
+ compilation.GetTypeByMetadataName("System.IDisposable") is not null
+ && focus.Symbol.Name != "Count",
+ id: "CUSTOM003",
+ title: "No property named Count",
+ message: "Property 'Count' is reserved"))
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public record Widget
+{
+ public int Count { get; init; }
+}
+";
+ var diagnostics = RunAnalyzer(shape, source);
+
+ diagnostics.ShouldContain(d => d.Id == "CUSTOM003");
+ }
+
+ [Fact]
+ public void Satisfy_null_predicate_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.Satisfy(predicate: (Func)null!, id: "X", title: "t", message: "m")));
+ }
+
+ [Fact]
+ public void Satisfy_empty_id_throws()
+ {
+ Should.Throw(() =>
+ Stencil.ExposeRecord()
+ .Members(kind: SymbolKind.Property, configure: m =>
+ m.Satisfy(predicate: static _ => true, id: "", title: "t", message: "m")));
+ }
+
+ 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/ShapeAnalyzerTests.cs b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs
index f2d1f86..c863e72 100644
--- a/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs
+++ b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs
@@ -27,9 +27,9 @@ public class ShapeAnalyzerTests
[Fact]
public void MustBePartial_emits_SCRIBE001_at_type_keyword_on_non_partial_class()
{
- var shape = Shape.Class()
+ var shape = Stencil.ExposeClass()
.MustBePartial()
- .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+ .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn));
var source = "public class Widget { }";
var diagnostics = RunAnalyzer(shape, source);
@@ -46,9 +46,9 @@ public void MustBePartial_emits_SCRIBE001_at_type_keyword_on_non_partial_class()
[Fact]
public void MustBePartial_emits_nothing_when_class_is_partial()
{
- var shape = Shape.Class()
+ var shape = Stencil.ExposeClass()
.MustBePartial()
- .Project