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((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public partial class Widget { }"); @@ -58,9 +58,9 @@ public void MustBePartial_emits_nothing_when_class_is_partial() [Fact] public void MustBeSealed_emits_SCRIBE005_at_type_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -75,10 +75,10 @@ public void Secondary_MustHaveAttribute_emits_SCRIBE003_and_encodes_attribute_na // First MustHaveAttribute is promoted to the selector — it narrows the shape's // scope to types that carry ThingAttribute. A second MustHaveAttribute runs as // a regular check on those narrowed types. - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("ThingAttribute") .MustHaveAttribute("MarkerAttribute") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class ThingAttribute : System.Attribute { } @@ -97,9 +97,9 @@ [Thing] public class Widget { } [Fact] public void MustImplement_squiggles_at_base_list_when_present_and_encodes_interface() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; }"; var diagnostics = RunAnalyzer(shape, source); @@ -116,10 +116,10 @@ public void MustImplement_squiggles_at_base_list_when_present_and_encodes_interf [Fact] public void Analyzer_ignores_types_that_do_not_carry_the_primary_attribute() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("ThingAttribute") .MustBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class ThingAttribute : System.Attribute { } diff --git a/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs index 2c04202..c7e364d 100644 --- a/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs +++ b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs @@ -29,10 +29,10 @@ public class ShapeFixAllProviderTests [Fact] public async Task FixAll_chains_partial_and_sealed_fixes_on_same_type() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePartial() .MustBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await RunFixAll(shape, "public class Widget { }", FixAllScope.Document); @@ -44,10 +44,10 @@ public async Task FixAll_chains_partial_and_sealed_fixes_on_same_type() [Fact] public async Task FixAll_chains_visibility_plus_remove_modifier_fixes() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePublic() .MustNotBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await RunFixAll(shape, "internal sealed class Widget { }", FixAllScope.Document); @@ -60,9 +60,9 @@ public async Task FixAll_chains_visibility_plus_remove_modifier_fixes() [Fact] public async Task FixAll_applies_fixes_per_type_independently() { - 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 { } @@ -95,7 +95,7 @@ private static async Task RunFixAll( diagnostics.Length.ShouldBeGreaterThan(0); - var fixProvider = shape.ToFixProvider(); + var fixProvider = shape.ToInk(); var fixAll = fixProvider.GetFixAllProvider(); fixAll.ShouldNotBeNull(); diff --git a/Scribe.Tests/Shapes/ShapeFixProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs index d86de79..0b7f273 100644 --- a/Scribe.Tests/Shapes/ShapeFixProviderTests.cs +++ b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs @@ -18,7 +18,7 @@ namespace Scribe.Tests.Shapes; /// -/// Validates that dispatches +/// Validates that dispatches /// each to the correct code action and produces the /// expected source transformation. /// @@ -31,9 +31,9 @@ public class ShapeFixProviderTests [Fact] public async Task AddPartialModifier_adds_partial_keyword() { - 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 result = await ApplyFirstFix(shape, "public class Widget { }"); result.ShouldContain("partial class Widget"); @@ -42,9 +42,9 @@ public async Task AddPartialModifier_adds_partial_keyword() [Fact] public async Task AddSealedModifier_adds_sealed_keyword_before_partial() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public partial class Widget { }"); result.ShouldContain("sealed partial class Widget"); @@ -53,9 +53,9 @@ public async Task AddSealedModifier_adds_sealed_keyword_before_partial() [Fact] public async Task AddInterfaceToBaseList_appends_interface_when_base_list_absent() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public sealed class Widget { public void Dispose() { } }"); result.ShouldContain(": System.IDisposable"); @@ -64,9 +64,9 @@ public async Task AddInterfaceToBaseList_appends_interface_when_base_list_absent [Fact] public async Task AddInterfaceToBaseList_appends_to_existing_base_list() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; public void Dispose() { } }"; var result = await ApplyFirstFix(shape, source); @@ -78,10 +78,10 @@ public async Task AddInterfaceToBaseList_appends_to_existing_base_list() public async Task AddAttribute_prepends_attribute_without_Attribute_suffix() { // Primary selector is ThingAttribute; Marker is the secondary must-have that fires. - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("ThingAttribute") .MustHaveAttribute("MarkerAttribute") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class ThingAttribute : System.Attribute { } @@ -94,15 +94,15 @@ [Thing] public class Widget { } } [Fact] - public async Task ToFixProvider_reports_all_check_ids_as_fixable() + public async Task ToInk_reports_all_check_ids_as_fixable() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePartial() .MustBeSealed() .MustImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); - var provider = shape.ToFixProvider(); + var provider = shape.ToInk(); provider.FixableDiagnosticIds.ShouldBe(_allThreeIds); } @@ -126,7 +126,7 @@ private static async Task ApplyFirstFix(Shape shape, str var first = diagnostics.OrderBy(d => d.Location.SourceSpan.Start).First(); - var fixProvider = shape.ToFixProvider(); + var fixProvider = shape.ToInk(); var actions = new List(); var context = new CodeFixContext( document, diff --git a/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs index 23881af..d1b62c5 100644 --- a/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs +++ b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs @@ -30,9 +30,9 @@ public class ShapePhase8PrimitivesTests [Fact] public void MustBeAbstract_emits_SCRIBE015_with_AddAbstractModifier_fix_kind() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeAbstract() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -44,9 +44,9 @@ public void MustBeAbstract_emits_SCRIBE015_with_AddAbstractModifier_fix_kind() [Fact] public void MustBeAbstract_emits_nothing_when_already_abstract() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeAbstract() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public abstract class Widget { }").ShouldBeEmpty(); } @@ -54,9 +54,9 @@ public void MustBeAbstract_emits_nothing_when_already_abstract() [Fact] public async Task MustBeAbstract_fix_adds_abstract_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeAbstract() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public class Widget { }"); result.ShouldContain("abstract class Widget"); @@ -65,9 +65,9 @@ public async Task MustBeAbstract_fix_adds_abstract_keyword() [Fact] public void MustBeStatic_emits_SCRIBE017_with_AddStaticModifier_fix_kind() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeStatic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -79,9 +79,9 @@ public void MustBeStatic_emits_SCRIBE017_with_AddStaticModifier_fix_kind() [Fact] public async Task MustBeStatic_fix_adds_static_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeStatic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public class Widget { }"); result.ShouldContain("static class Widget"); @@ -90,10 +90,10 @@ public async Task MustBeStatic_fix_adds_static_keyword() [Fact] public void MustExtend_emits_SCRIBE009_and_encodes_base_class_in_fix_properties() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("MarkerAttribute") .MustExtend("MyBase") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -112,10 +112,10 @@ [Marker] public class Widget { } [Fact] public void MustExtend_emits_nothing_when_already_extends() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("MarkerAttribute") .MustExtend("MyBase") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -128,10 +128,10 @@ public class MyBase { } [Fact] public async Task MustExtend_fix_inserts_base_class_at_head_of_base_list() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("MarkerAttribute") .MustExtend("MyBase") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -146,9 +146,9 @@ public class MyBase { } [Fact] public void MustBeInNamespace_emits_SCRIBE027_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" namespace MyApp.Other { public class Widget { } } @@ -162,9 +162,9 @@ namespace MyApp.Other { public class Widget { } } [Fact] public void MustBeInNamespace_allows_matching_namespace() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" namespace MyApp.Domain.Things { public class Widget { } } @@ -175,9 +175,9 @@ namespace MyApp.Domain.Things { public class Widget { } } [Fact] public void MustNotBeAbstract_emits_SCRIBE016_with_RemoveAbstractModifier_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeAbstract() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public abstract class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -188,9 +188,9 @@ public void MustNotBeAbstract_emits_SCRIBE016_with_RemoveAbstractModifier_fix() [Fact] public async Task MustNotBeAbstract_fix_removes_abstract_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeAbstract() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public abstract class Widget { }"); result.ShouldNotContain("abstract"); @@ -200,9 +200,9 @@ public async Task MustNotBeAbstract_fix_removes_abstract_keyword() [Fact] public void MustNotBeGeneric_emits_SCRIBE024_with_no_fix_on_generic_class() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeGeneric() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -213,9 +213,9 @@ public void MustNotBeGeneric_emits_SCRIBE024_with_no_fix_on_generic_class() [Fact] public void MustNotBeGeneric_allows_non_generic_class() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeGeneric() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); } @@ -223,9 +223,9 @@ public void MustNotBeGeneric_allows_non_generic_class() [Fact] public void MustNotImplement_emits_SCRIBE008_and_encodes_interface_for_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget : System.IDisposable { public void Dispose() { } }"; var diagnostics = RunAnalyzer(shape, source); @@ -238,9 +238,9 @@ public void MustNotImplement_emits_SCRIBE008_and_encodes_interface_for_fix() [Fact] public async Task MustNotImplement_fix_removes_forbidden_interface_from_base_list() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotImplement("System.IDisposable") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget : System.IComparable, System.IDisposable { public int CompareTo(object o) => 0; public void Dispose() { } }"; var result = await ApplyFirstFix(shape, source); @@ -295,7 +295,7 @@ private static async Task ApplyFirstFix(Shape shape, str && !string.IsNullOrEmpty(k) && k != "None"); - var fixProvider = shape.ToFixProvider(); + var fixProvider = shape.ToInk(); var actions = new List(); var context = new CodeFixContext( document, diff --git a/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs index fdb7615..5b07080 100644 --- a/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs +++ b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs @@ -34,9 +34,9 @@ public class ShapePhase8_5Tests [Fact] public void MustNotBePartial_emits_SCRIBE002_with_RemovePartialModifier_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public partial class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -47,9 +47,9 @@ public void MustNotBePartial_emits_SCRIBE002_with_RemovePartialModifier_fix() [Fact] public async Task MustNotBePartial_fix_removes_partial_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public partial class Widget { }"); result.ShouldNotContain("partial"); @@ -59,9 +59,9 @@ public async Task MustNotBePartial_fix_removes_partial_keyword() [Fact] public void MustNotBeSealed_emits_SCRIBE006_with_RemoveSealedModifier_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public sealed class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -72,9 +72,9 @@ public void MustNotBeSealed_emits_SCRIBE006_with_RemoveSealedModifier_fix() [Fact] public async Task MustNotBeSealed_fix_removes_sealed_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeSealed() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public sealed class Widget { }"); result.ShouldNotContain("sealed"); @@ -84,9 +84,9 @@ public async Task MustNotBeSealed_fix_removes_sealed_keyword() [Fact] public void MustNotHaveAttribute_emits_SCRIBE004_with_RemoveAttribute_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotHaveAttribute("MarkerAttribute") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -104,9 +104,9 @@ [Marker] public class Widget { } [Fact] public async Task MustNotHaveAttribute_fix_removes_attribute_from_type() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotHaveAttribute("MarkerAttribute") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -120,9 +120,9 @@ [Marker] public class Widget { } [Fact] public void MustNotBeNamed_emits_SCRIBE030_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeNamed(@"^.*Impl$") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class WidgetImpl { }"); diagnostics.Length.ShouldBe(1); @@ -133,9 +133,9 @@ public void MustNotBeNamed_emits_SCRIBE030_with_no_fix() [Fact] public void MustNotBeStatic_emits_SCRIBE018_with_RemoveStaticModifier_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeStatic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public static class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -146,9 +146,9 @@ public void MustNotBeStatic_emits_SCRIBE018_with_RemoveStaticModifier_fix() [Fact] public async Task MustNotBeStatic_fix_removes_static_keyword() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeStatic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public static class Widget { }"); result.ShouldNotContain("static"); @@ -158,10 +158,10 @@ public async Task MustNotBeStatic_fix_removes_static_keyword() [Fact] public void MustNotExtend_emits_SCRIBE010_and_encodes_baseClass_for_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("MarkerAttribute") .MustNotExtend("MyBase") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -180,10 +180,10 @@ public class MyBase { } [Fact] public async Task MustNotExtend_fix_removes_base_class_from_base_list() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("MarkerAttribute") .MustNotExtend("MyBase") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = @" public sealed class MarkerAttribute : System.Attribute { } @@ -199,9 +199,9 @@ public class MyBase { } [Fact] public void MustNotBeInNamespace_emits_SCRIBE028_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeInNamespace(@"^MyApp\.Legacy(\..*)?$") - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "namespace MyApp.Legacy { public class Widget { } }"; var diagnostics = RunAnalyzer(shape, source); @@ -213,9 +213,9 @@ public void MustNotBeInNamespace_emits_SCRIBE028_with_no_fix() [Fact] public void MustBeGeneric_emits_SCRIBE023_on_non_generic_class_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeGeneric() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -226,9 +226,9 @@ public void MustBeGeneric_emits_SCRIBE023_on_non_generic_class_with_no_fix() [Fact] public void MustBeGeneric_allows_generic_class() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeGeneric() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); } @@ -240,9 +240,9 @@ public void MustBeGeneric_allows_generic_class() [Fact] public void MustBePublic_emits_SCRIBE011_with_SetVisibility_fix_and_visibility_property() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePublic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "internal class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -254,9 +254,9 @@ public void MustBePublic_emits_SCRIBE011_with_SetVisibility_fix_and_visibility_p [Fact] public async Task MustBePublic_fix_rewrites_visibility_to_public() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePublic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "internal class Widget { }"); result.ShouldContain("public class Widget"); @@ -266,9 +266,9 @@ public async Task MustBePublic_fix_rewrites_visibility_to_public() [Fact] public void MustBeInternal_emits_SCRIBE013_with_SetVisibility_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeInternal() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -279,9 +279,9 @@ public void MustBeInternal_emits_SCRIBE013_with_SetVisibility_fix() [Fact] public async Task MustBeInternal_fix_rewrites_visibility_to_internal() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeInternal() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var result = await ApplyFirstFix(shape, "public class Widget { }"); result.ShouldContain("internal class Widget"); @@ -291,9 +291,9 @@ public async Task MustBeInternal_fix_rewrites_visibility_to_internal() [Fact] public void MustNotBePublic_emits_SCRIBE012_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBePublic() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -304,9 +304,9 @@ public void MustNotBePublic_emits_SCRIBE012_with_no_fix() [Fact] public void MustNotBeInternal_allows_public_class() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustNotBeInternal() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); } @@ -318,9 +318,9 @@ public void MustNotBeInternal_allows_public_class() [Fact] public void MustBeRecord_emits_SCRIBE019_on_plain_class_with_no_fix() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeRecord() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -331,9 +331,9 @@ public void MustBeRecord_emits_SCRIBE019_on_plain_class_with_no_fix() [Fact] public void MustBeRecord_allows_record_class() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBeRecord() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public record class Widget(int X);").ShouldBeEmpty(); } @@ -341,9 +341,9 @@ public void MustBeRecord_allows_record_class() [Fact] public void MustNotBeRecord_emits_SCRIBE020_on_record() { - var shape = Shape.Record() + var shape = Stencil.ExposeRecord() .MustNotBeRecord() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public record class Widget(int X);"); diagnostics.Length.ShouldBe(1); @@ -353,15 +353,15 @@ public void MustNotBeRecord_emits_SCRIBE020_on_record() [Fact] public void MustBeValueType_emits_SCRIBE021_on_class_and_skips_struct() { - var shape = Shape.Struct() + var shape = Stencil.ExposeStruct() .MustBeValueType() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public struct Widget { }").ShouldBeEmpty(); - var shapeClass = Shape.Class() + var shapeClass = Stencil.ExposeClass() .MustBeValueType() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shapeClass, "public class Widget { }"); diagnostics.Length.ShouldBe(1); @@ -371,9 +371,9 @@ public void MustBeValueType_emits_SCRIBE021_on_class_and_skips_struct() [Fact] public void MustNotBeValueType_emits_SCRIBE022_on_struct() { - var shape = Shape.Struct() + var shape = Stencil.ExposeStruct() .MustNotBeValueType() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public struct Widget { }"); diagnostics.Length.ShouldBe(1); @@ -387,9 +387,9 @@ public void MustNotBeValueType_emits_SCRIBE022_on_struct() [Fact] public void MustHaveParameterlessConstructor_allows_class_with_no_explicit_ctors() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveParameterlessConstructor() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); } @@ -397,9 +397,9 @@ public void MustHaveParameterlessConstructor_allows_class_with_no_explicit_ctors [Fact] public void MustHaveParameterlessConstructor_emits_SCRIBE031_when_only_parameterised_ctor_exists() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveParameterlessConstructor() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget { public Widget(int x) { } }"; var diagnostics = RunAnalyzer(shape, source); @@ -411,9 +411,9 @@ public void MustHaveParameterlessConstructor_emits_SCRIBE031_when_only_parameter [Fact] public async Task MustHaveParameterlessConstructor_fix_adds_public_parameterless_ctor() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveParameterlessConstructor() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var source = "public class Widget { public Widget(int x) { } }"; var result = await ApplyFirstFix(shape, source); @@ -471,7 +471,7 @@ private static async Task ApplyFirstFix(Shape shape, str && !string.IsNullOrEmpty(k) && k != "None"); - var fixProvider = shape.ToFixProvider(); + var fixProvider = shape.ToInk(); var actions = new List(); var context = new CodeFixContext( document, diff --git a/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs index c5e00cf..bcad489 100644 --- a/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs +++ b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs @@ -26,9 +26,9 @@ public class ShapeSeverityVariantsTests [Fact] public void ShouldBePartial_reports_SCRIBE001_at_Warning_severity() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .ShouldBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -40,9 +40,9 @@ public void ShouldBePartial_reports_SCRIBE001_at_Warning_severity() [Fact] public void CouldBePartial_reports_SCRIBE001_at_Info_severity() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .CouldBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -54,12 +54,12 @@ public void CouldBePartial_reports_SCRIBE001_at_Info_severity() [Fact] public void MustBePartial_ShouldBePartial_and_CouldBePartial_all_share_SCRIBE001() { - var must = Shape.Class().MustBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); - var should = Shape.Class().ShouldBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); - var could = Shape.Class().CouldBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + var must = Stencil.ExposeClass().MustBePartial() + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + var should = Stencil.ExposeClass().ShouldBePartial() + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + var could = Stencil.ExposeClass().CouldBePartial() + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); const string source = "public class Widget { }"; @@ -71,9 +71,9 @@ public void MustBePartial_ShouldBePartial_and_CouldBePartial_all_share_SCRIBE001 [Fact] public void Pragma_warning_disable_SCRIBE001_silences_the_ShouldBe_variant() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .ShouldBePartial() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); const string source = @" #pragma warning disable SCRIBE001 @@ -89,9 +89,9 @@ public class Widget { } public void Caller_supplied_severity_wins_over_variant_default() { // ShouldBePartial defaults to Warning, but the caller pins Hidden. - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .ShouldBePartial(new DiagnosticSpec(Severity: DiagnosticSeverity.Hidden)) - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); @@ -104,9 +104,9 @@ public void ShouldImplement_generic_variant_flows_through_type_parameter_constra { // Exercises the Scriptorium-generated generic overload with its // `where T : class` constraint, plus severity demotion. - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .ShouldImplement() - .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); var diagnostics = RunAnalyzer(shape, "public class Widget { }"); diff --git a/Scribe.Tests/Shapes/ShapeToProviderTests.cs b/Scribe.Tests/Shapes/ShapeToProviderTests.cs index 6c66992..625bc98 100644 --- a/Scribe.Tests/Shapes/ShapeToProviderTests.cs +++ b/Scribe.Tests/Shapes/ShapeToProviderTests.cs @@ -24,10 +24,10 @@ private sealed class CapturingGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("ThingAttribute") .MustBePartial() - .Project((in ShapeProjectionContext ctx) => new CollectedModel( + .Etch((in ShapeEtchContext ctx) => new CollectedModel( Fqn: ctx.Fqn, ViolationCount: 0)); @@ -53,9 +53,9 @@ private sealed class NoAttributeGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePartial() - .Project((in ShapeProjectionContext ctx) => new CollectedModel( + .Etch((in ShapeEtchContext ctx) => new CollectedModel( Fqn: ctx.Fqn, ViolationCount: 0)); @@ -146,9 +146,9 @@ public void ToProvider_ProjectionReceivesAttributeReader() var generator = new InlineGenerator(ctx => { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustHaveAttribute("FooAttribute") - .Project((in ShapeProjectionContext pc) => new ValueModel( + .Etch((in ShapeEtchContext pc) => new ValueModel( pc.Fqn, pc.Attribute.Ctor(0) ?? "")); diff --git a/Scribe.Tests/Shapes/TypeArgFocusLeafPredicatesTests.cs b/Scribe.Tests/Shapes/TypeArgFocusLeafPredicatesTests.cs new file mode 100644 index 0000000..99d71ac --- /dev/null +++ b/Scribe.Tests/Shapes/TypeArgFocusLeafPredicatesTests.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 MustImplement and +/// MustDeriveFrom — reached via a generic type argument on an attribute +/// application. +/// +public class TypeArgFocusLeafPredicatesTests +{ + private readonly record struct Collected(string Fqn); + + [Fact] + public void MustImplement_fires_when_type_arg_does_not_implement_interface() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.MustImplement("System.IDisposable"))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Payload { } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldContain(d => d.Id == "SCRIBE070"); + } + + [Fact] + public void MustImplement_is_silent_when_type_arg_implements_interface() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.MustImplement("System.IDisposable"))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Payload : System.IDisposable { public void Dispose() { } } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void MustDeriveFrom_fires_when_type_arg_does_not_derive_from_base() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.MustDeriveFrom("System.Exception"))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Payload { } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldContain(d => d.Id == "SCRIBE071"); + } + + [Fact] + public void MustDeriveFrom_is_silent_when_type_arg_derives_transitively() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.MustDeriveFrom("System.Exception"))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Payload : System.InvalidOperationException { } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void MustImplement_null_shape_throws() + { + Should.Throw(() => + TypeArgFocusLeafPredicates.MustImplement(null!, "System.IDisposable")); + } + + [Fact] + public void MustImplement_empty_metadata_name_throws() + { + Should.Throw(() => + Stencil.ExposeRecord().Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => arg.MustImplement("")))); + } + + 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/TypeFocusLeafPredicatesTests.cs b/Scribe.Tests/Shapes/TypeFocusLeafPredicatesTests.cs new file mode 100644 index 0000000..d878382 --- /dev/null +++ b/Scribe.Tests/Shapes/TypeFocusLeafPredicatesTests.cs @@ -0,0 +1,192 @@ +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 representative v1 subset of leaf predicates on +/// MustBePartial, MustBeSealed, +/// MustBeAbstract, MustBeStatic, MustHaveAttribute, +/// MustBeNamed — composed through AsTypeShape() so the checks run on +/// a nested reached via a lens. +/// +public class TypeFocusLeafPredicatesTests +{ + private readonly record struct Collected(string Fqn); + + [Fact] + public void MustBeSealed_fires_when_nested_type_argument_is_not_sealed() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.AsTypeShape(t => t.MustBeSealed()))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + 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("SCRIBE005"); + diagnostics[0].GetMessage(CultureInfo.InvariantCulture).ShouldContain("Payload"); + } + + [Fact] + public void MustBeSealed_is_silent_when_nested_type_argument_is_sealed() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.AsTypeShape(t => t.MustBeSealed()))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public sealed class Payload { } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void MustBeAbstract_fires_when_nested_base_is_concrete() + { + var shape = Stencil.ExposeRecord() + .BaseTypeChain(configure: step => + step.AsTypeShape(t => t.MustBeAbstract())) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public class Gadget { } +public record Widget : Gadget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE015"); + } + + [Fact] + public void MustBeAbstract_is_silent_when_nested_base_is_abstract() + { + var shape = Stencil.ExposeRecord() + .BaseTypeChain(configure: step => + step.AsTypeShape(t => t.MustBeAbstract())) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public abstract class Gadget { } +public record Widget : Gadget; +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void MustHaveAttribute_fires_when_nested_type_argument_is_missing_required_attribute() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.AsTypeShape(t => t.MustHaveAttribute("System.ObsoleteAttribute")))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + 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("SCRIBE003"); + } + + [Fact] + public void MustBeNamed_fires_when_nested_type_argument_name_violates_pattern() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.AsTypeShape(t => t.MustBeNamed(@"^[A-Z][a-zA-Z0-9]*Dto$")))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + 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("SCRIBE029"); + } + + [Fact] + public void MustBePartial_and_MustBeStatic_compose_on_nested_type_focus() + { + var shape = Stencil.ExposeRecord() + .Attributes("ThingAttribute", configure: attr => + attr.GenericTypeArg(index: 0, configure: arg => + arg.AsTypeShape(t => t.MustBePartial().MustBeStatic()))) + .Etch((in ShapeEtchContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Payload { } +[Thing] public record Widget; +"; + var diagnostics = RunAnalyzer(shape, source); + + // Payload is neither partial nor static → both fire. + diagnostics.Length.ShouldBe(2); + diagnostics.ShouldContain(d => d.Id == "SCRIBE001"); + diagnostics.ShouldContain(d => d.Id == "SCRIBE017"); + } + + [Fact] + public void MustBeSealed_null_shape_throws() + { + Should.Throw(() => + TypeFocusLeafPredicates.MustBeSealed(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/ShapeBuilderTests.cs b/Scribe.Tests/Shapes/TypeShapeTests.cs similarity index 82% rename from Scribe.Tests/Shapes/ShapeBuilderTests.cs rename to Scribe.Tests/Shapes/TypeShapeTests.cs index 89ee3fb..de2f2a1 100644 --- a/Scribe.Tests/Shapes/ShapeBuilderTests.cs +++ b/Scribe.Tests/Shapes/TypeShapeTests.cs @@ -8,13 +8,13 @@ namespace Scribe.Tests.Shapes; /// -/// Unit-tests the per-primitive predicate behaviour of . +/// Unit-tests the per-primitive predicate behaviour of . /// Each test parses a compilation, picks out a named type, and invokes the /// builder's internal check pipeline via /// round-trips would be over-weight here — we probe the predicates directly /// through a projected Shape on a synthetic dummy model. /// -public class ShapeBuilderTests +public class TypeShapeTests { private readonly record struct TypeNameModel(string Name); @@ -42,20 +42,21 @@ private static (CSharpCompilation Compilation, INamedTypeSymbol Symbol) Compile( private static INamedTypeSymbol GetType(string source, string metadataName) => Compile(source, metadataName).Symbol; - private static EquatableArray RunChecks(ShapeBuilder builder, string source, string metadataName) + private static EquatableArray RunChecks(TypeShape builder, string source, string metadataName) { var (compilation, symbol) = Compile(source, metadataName); var ct = TestContext.Current.CancellationToken; var diags = new System.Collections.Generic.List(); + var focus = new TypeFocus(symbol, symbol.ToDisplayString(), origin: null); foreach (var check in builder.Checks) { - if (!check.Predicate(symbol, compilation, ct)) + if (!check.Predicate(focus, compilation, ct)) { diags.Add(new DiagnosticInfo( Id: check.Id, Severity: check.Severity, - MessageArgs: check.MessageArgs(symbol), + MessageArgs: check.MessageArgs(focus), Location: null)); } } @@ -64,7 +65,7 @@ private static EquatableArray RunChecks(ShapeBuilder builder, st } // Convenience overload for tests that already hold a compiled symbol. - private static EquatableArray RunChecks(ShapeBuilder builder, INamedTypeSymbol symbol) + private static EquatableArray RunChecks(TypeShape builder, INamedTypeSymbol symbol) { var ct = TestContext.Current.CancellationToken; var tree = symbol.DeclaringSyntaxReferences[0].SyntaxTree; @@ -81,16 +82,17 @@ private static EquatableArray RunChecks(ShapeBuilder builder, IN MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location), ]); var reResolved = compilation.GetTypeByMetadataName(symbol.ToDisplayString())!; + var focus = new TypeFocus(reResolved, reResolved.ToDisplayString(), origin: null); var diags = new System.Collections.Generic.List(); foreach (var check in builder.Checks) { - if (!check.Predicate(reResolved, compilation, ct)) + if (!check.Predicate(focus, compilation, ct)) { diags.Add(new DiagnosticInfo( Id: check.Id, Severity: check.Severity, - MessageArgs: check.MessageArgs(reResolved), + MessageArgs: check.MessageArgs(focus), Location: null)); } } @@ -106,7 +108,7 @@ private static EquatableArray RunChecks(ShapeBuilder builder, IN public void MustBePartial_PassesForPartialClass() { var symbol = GetType("public partial class Foo {}", "Foo"); - var builder = Shape.Class().MustBePartial(); + var builder = Stencil.ExposeClass().MustBePartial(); RunChecks(builder, symbol).IsEmpty.ShouldBeTrue(); } @@ -114,7 +116,7 @@ public void MustBePartial_PassesForPartialClass() public void MustBePartial_FailsForNonPartialClass() { var symbol = GetType("public class Foo {}", "Foo"); - var builder = Shape.Class().MustBePartial(); + var builder = Stencil.ExposeClass().MustBePartial(); var diags = RunChecks(builder, symbol); diags.Count.ShouldBe(1); @@ -126,7 +128,7 @@ public void MustBePartial_FailsForNonPartialClass() public void MustBePartial_UsesOverrideId() { var symbol = GetType("public class Foo {}", "Foo"); - var builder = Shape.Class().MustBePartial(new DiagnosticSpec(Id: "CUSTOM01")); + var builder = Stencil.ExposeClass().MustBePartial(new DiagnosticSpec(Id: "CUSTOM01")); RunChecks(builder, symbol)[0].Id.ShouldBe("CUSTOM01"); } @@ -139,14 +141,14 @@ public void MustBePartial_UsesOverrideId() public void MustBeSealed_PassesForSealedClass() { var symbol = GetType("public sealed class Foo {}", "Foo"); - RunChecks(Shape.Class().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeClass().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); } [Fact] public void MustBeSealed_FailsForOpenClass() { var symbol = GetType("public class Foo {}", "Foo"); - var diags = RunChecks(Shape.Class().MustBeSealed(), symbol); + var diags = RunChecks(Stencil.ExposeClass().MustBeSealed(), symbol); diags.Count.ShouldBe(1); diags[0].Id.ShouldBe("SCRIBE005"); @@ -156,7 +158,7 @@ public void MustBeSealed_FailsForOpenClass() public void MustBeSealed_PassesForValueType() { var symbol = GetType("public struct Foo {}", "Foo"); - RunChecks(Shape.Struct().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeStruct().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); } // ─────────────────────────────────────────────────────────────── @@ -169,14 +171,14 @@ public void MustImplement_PassesWhenInterfaceImplemented() var symbol = GetType( "using System; public class Foo : IDisposable { public void Dispose() {} }", "Foo"); - RunChecks(Shape.Class().MustImplement(), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeClass().MustImplement(), symbol).IsEmpty.ShouldBeTrue(); } [Fact] public void MustImplement_FailsWhenInterfaceMissing() { var symbol = GetType("public class Foo {}", "Foo"); - var diags = RunChecks(Shape.Class().MustImplement(), symbol); + var diags = RunChecks(Stencil.ExposeClass().MustImplement(), symbol); diags.Count.ShouldBe(1); diags[0].Id.ShouldBe("SCRIBE007"); @@ -188,7 +190,7 @@ public void MustImplement_ByMetadataName_Works() var symbol = GetType( "using System; public class Foo : IDisposable { public void Dispose() {} }", "Foo"); - RunChecks(Shape.Class().MustImplement("System.IDisposable"), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeClass().MustImplement("System.IDisposable"), symbol).IsEmpty.ShouldBeTrue(); } // ─────────────────────────────────────────────────────────────── @@ -204,14 +206,14 @@ public sealed class FooAttribute : Attribute {} [Foo] public class Target {} """, "Target"); - RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeClass().MustHaveAttribute("FooAttribute"), symbol).IsEmpty.ShouldBeTrue(); } [Fact] public void MustHaveAttribute_FailsWhenAttributeMissing() { var symbol = GetType("public class Target {}", "Target"); - var diags = RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol); + var diags = RunChecks(Stencil.ExposeClass().MustHaveAttribute("FooAttribute"), symbol); diags.Count.ShouldBe(1); diags[0].Id.ShouldBe("SCRIBE003"); @@ -220,14 +222,14 @@ public void MustHaveAttribute_FailsWhenAttributeMissing() [Fact] public void MustHaveAttribute_SetsPrimaryAttributeMetadataName() { - var builder = Shape.Class().MustHaveAttribute("FooAttribute"); + var builder = Stencil.ExposeClass().MustHaveAttribute("FooAttribute"); builder.PrimaryAttributeMetadataName.ShouldBe("FooAttribute"); } [Fact] public void MustHaveAttribute_SecondCall_DoesNotOverridePrimary() { - var builder = Shape.Class() + var builder = Stencil.ExposeClass() .MustHaveAttribute("FirstAttribute") .MustHaveAttribute("SecondAttribute"); builder.PrimaryAttributeMetadataName.ShouldBe("FirstAttribute"); @@ -242,14 +244,14 @@ public void MustHaveAttribute_SecondCall_DoesNotOverridePrimary() public void MustBeNamed_PassesWhenMatches() { var symbol = GetType("public class FooHandler {}", "FooHandler"); - RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol).IsEmpty.ShouldBeTrue(); + RunChecks(Stencil.ExposeClass().MustBeNamed(".*Handler$"), symbol).IsEmpty.ShouldBeTrue(); } [Fact] public void MustBeNamed_FailsWhenDoesNotMatch() { var symbol = GetType("public class FooManager {}", "FooManager"); - var diags = RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol); + var diags = RunChecks(Stencil.ExposeClass().MustBeNamed(".*Handler$"), symbol); diags.Count.ShouldBe(1); diags[0].Id.ShouldBe("SCRIBE029"); @@ -263,7 +265,7 @@ public void MustBeNamed_FailsWhenDoesNotMatch() public void MultipleChecks_AllFail_YieldsAllDiagnostics() { var symbol = GetType("public class Foo {}", "Foo"); - var builder = Shape.Class() + var builder = Stencil.ExposeClass() .MustBePartial() .MustBeSealed() .MustBeNamed(".*Handler$"); @@ -277,9 +279,9 @@ public void MultipleChecks_AllFail_YieldsAllDiagnostics() [Fact] public void ProjectSealsBuilder_IntoTypedShape() { - var shape = Shape.Class() + var shape = Stencil.ExposeClass() .MustBePartial() - .Project((in ShapeProjectionContext ctx) => new TypeNameModel(ctx.Symbol.Name)); + .Etch((in ShapeEtchContext ctx) => new TypeNameModel(ctx.Symbol.Name)); shape.ShouldNotBeNull(); } diff --git a/Scribe/Cache/DiagnosticInfo.cs b/Scribe/Cache/DiagnosticInfo.cs index e5050dd..c3b3583 100644 --- a/Scribe/Cache/DiagnosticInfo.cs +++ b/Scribe/Cache/DiagnosticInfo.cs @@ -19,11 +19,15 @@ public readonly record struct DiagnosticInfo( string Id, DiagnosticSeverity Severity, EquatableArray MessageArgs, - LocationInfo? Location) + LocationInfo? Location, + string? FocusPath = null) { /// /// Combine this cache-safe record with the user-supplied descriptor and produce a - /// real for reporting. + /// real for reporting. When is + /// non-null, the rendered message is prefixed with "[path] " — the B.8 + /// focus-path breadcrumb that tells the reader which lens hops produced the + /// violation. /// /// /// The descriptor to use. Its should match @@ -32,9 +36,32 @@ public readonly record struct DiagnosticInfo( public Diagnostic Materialize(DiagnosticDescriptor descriptor) { var location = Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None; - return MessageArgs.IsEmpty - ? Diagnostic.Create(descriptor, location) - : Diagnostic.Create(descriptor, location, ToObjectArray(MessageArgs)); + + if (FocusPath is null) + { + return MessageArgs.IsEmpty + ? Diagnostic.Create(descriptor, location) + : Diagnostic.Create(descriptor, location, ToObjectArray(MessageArgs)); + } + + var rendered = MessageArgs.IsEmpty + ? descriptor.MessageFormat.ToString(System.Globalization.CultureInfo.InvariantCulture) + : string.Format( + System.Globalization.CultureInfo.InvariantCulture, + descriptor.MessageFormat.ToString(System.Globalization.CultureInfo.InvariantCulture), + ToObjectArray(MessageArgs)); + + var wrapped = new DiagnosticDescriptor( + id: descriptor.Id, + title: descriptor.Title, + messageFormat: "[" + FocusPath + "] " + rendered, + category: descriptor.Category, + defaultSeverity: descriptor.DefaultSeverity, + isEnabledByDefault: descriptor.IsEnabledByDefault, + description: descriptor.Description, + helpLinkUri: descriptor.HelpLinkUri); + + return Diagnostic.Create(wrapped, location); } private static object?[] ToObjectArray(EquatableArray args) diff --git a/Scribe/Scribe.csproj b/Scribe/Scribe.csproj index 5b80ec5..67dfb04 100644 --- a/Scribe/Scribe.csproj +++ b/Scribe/Scribe.csproj @@ -27,7 +27,7 @@ diff --git a/Scribe/Shapes/AttributeFocus.cs b/Scribe/Shapes/AttributeFocus.cs new file mode 100644 index 0000000..05d1b88 --- /dev/null +++ b/Scribe/Shapes/AttributeFocus.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for an attribute usage on a symbol (type, member, or parameter). +/// Produced by the Attributes(fqn) lens. +/// +/// +/// +/// The identity breadcrumb is (AttributeFqn, OwnerFqn, Index, Origin). Two +/// attributes of the same class on the same owner are distinguished by their +/// positional [Foo][Foo] on a single type yields +/// two distinct foci. +/// +/// +/// is carried for in-flight predicate evaluation but excluded +/// from equality: Roslyn's AttributeData is not cache-stable across +/// compilations. +/// +/// +public readonly struct AttributeFocus : IEquatable +{ + /// The attribute data. Excluded from equality — do not use for caching. + public AttributeData Data { get; } + + /// Fully-qualified name of the attribute class (interned). + public string AttributeFqn { get; } + + /// Fully-qualified name of the symbol the attribute is applied to. + public string OwnerFqn { get; } + + /// + /// Zero-based index of this attribute within the owner's attribute list, used to + /// disambiguate repeated attributes of the same class on the same owner. + /// + public int Index { get; } + + /// Source span of the attribute application — the cache-stable breadcrumb. + public LocationInfo? Origin { get; } + + public AttributeFocus( + AttributeData data, + string attributeFqn, + string ownerFqn, + int index, + LocationInfo? origin) + { + Data = data; + AttributeFqn = attributeFqn; + OwnerFqn = ownerFqn; + Index = index; + Origin = origin; + } + + public bool Equals(AttributeFocus other) => + string.Equals(AttributeFqn, other.AttributeFqn, StringComparison.Ordinal) + && string.Equals(OwnerFqn, other.OwnerFqn, StringComparison.Ordinal) + && Index == other.Index + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is AttributeFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = AttributeFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(AttributeFqn); + hash = (hash * 397) ^ (OwnerFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(OwnerFqn)); + hash = (hash * 397) ^ Index; + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(AttributeFocus left, AttributeFocus right) => left.Equals(right); + + public static bool operator !=(AttributeFocus left, AttributeFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/AttributeFocusShapeExtensions.cs b/Scribe/Shapes/AttributeFocusShapeExtensions.cs new file mode 100644 index 0000000..4a2393a --- /dev/null +++ b/Scribe/Shapes/AttributeFocusShapeExtensions.cs @@ -0,0 +1,185 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Fluent sub-lens entry points on . These +/// extensions thread further navigation off a single attribute application — into +/// its generic type arguments, positional constructor arguments, and named +/// arguments — each producing a nested the caller can +/// populate with per-argument checks. +/// +public static class AttributeFocusShapeExtensions +{ + /// + /// Enter the generic-type-argument sub-lens: navigate the type argument at + /// position of the attribute's generic argument list — + /// e.g. index 0 of [Foo<string>]. Declares a presence + /// constraint when / are supplied; + /// min: 1 means "an argument at this index must exist". + /// + public static FocusShape GenericTypeArg( + this FocusShape shape, + int index, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.GenericTypeArg(index); + + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE053", + defaultTitle: "Attribute generic-type-argument presence constraint", + defaultMessage: "Expected [{0}..{1}] generic type arguments at index " + + index.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ", observed {2}"); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin)); + + return shape; + } + + /// + /// Enter the positional-constructor-argument sub-lens: navigate the argument at + /// position , coercing its value to + /// . Declares a presence constraint when + /// / are supplied; + /// min: 1 means "an argument of this type at this index must exist". + /// + public static FocusShape ConstructorArg( + this FocusShape shape, + int index, + Action>>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape>(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.ConstructorArg(index); + + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE054", + defaultTitle: "Attribute constructor-argument presence constraint", + defaultMessage: "Expected [{0}..{1}] constructor argument(s) of type '" + + typeof(T).FullName + + "' at index " + + index.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ", observed {2}"); + + shape.AddBranch(new LensBranch>( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin)); + + return shape; + } + + /// + /// Enter the named-argument sub-lens: navigate the argument named + /// , coercing its value to . + /// Declares a presence constraint when / + /// are supplied; min: 1 means "a named argument + /// with this name must exist". + /// + public static FocusShape NamedArg( + this FocusShape shape, + string name, + Action>>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape>(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.NamedArg(name); + + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE055", + defaultTitle: "Attribute named-argument presence constraint", + defaultMessage: "Expected [{0}..{1}] named argument(s) named '" + + name + "' of type '" + typeof(T).FullName + "', observed {2}"); + + shape.AddBranch(new LensBranch>( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin)); + + return shape; + } + + private static void ValidateCounts(int min, int? max) + { + if (min < 0) + { + throw new ArgumentOutOfRangeException(nameof(min), "Minimum count must be non-negative."); + } + + if (max is { } m && m < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Maximum count must be at least the minimum."); + } + } + + private static LensPresenceSpec? BuildPresence( + int min, + int? max, + DiagnosticSpec? spec, + string defaultId, + string defaultTitle, + string defaultMessage) + { + var hasPresence = min > 0 || max is not null || spec is not null; + if (!hasPresence) + { + return null; + } + + return new LensPresenceSpec( + Id: InternPool.Intern(spec?.Id ?? defaultId), + Title: spec?.Title ?? defaultTitle, + MessageFormat: spec?.Message ?? defaultMessage, + Severity: spec?.Severity ?? DiagnosticSeverity.Error); + } +} diff --git a/Scribe/Shapes/BaseTypeChainFocus.cs b/Scribe/Shapes/BaseTypeChainFocus.cs new file mode 100644 index 0000000..ced258b --- /dev/null +++ b/Scribe/Shapes/BaseTypeChainFocus.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for one step of a type's inheritance chain. The +/// BaseTypeChain() lens yields one per +/// chain element, ordered from the immediate base up to object. Quantifiers +/// (Any / All / None) apply across the per-step stream. +/// +/// +/// Identity breadcrumb is (TypeFqn, Depth, RootOrigin, Origin). +/// disambiguates the same (Fqn, Depth) pair when the chain +/// is navigated from two different starting types in the same compilation. +/// is excluded from equality. +/// +public readonly struct BaseTypeChainFocus : IEquatable +{ + /// The base type at this chain step. Excluded from equality. + public INamedTypeSymbol Symbol { get; } + + /// Fully-qualified name of the base type at this step (interned). + public string TypeFqn { get; } + + /// Depth above the starting type. 0 = immediate base, 1 = grandparent, … + public int Depth { get; } + + /// Source span of the chain's starting type — disambiguates identical chains from different roots. + public LocationInfo? RootOrigin { get; } + + /// + /// Source span of this chain step. Resolved from the base-list entry when + /// present; otherwise from the base type's own declaration. The cache-stable + /// breadcrumb for this focus. + /// + public LocationInfo? Origin { get; } + + public BaseTypeChainFocus( + INamedTypeSymbol symbol, + string typeFqn, + int depth, + LocationInfo? rootOrigin, + LocationInfo? origin) + { + Symbol = symbol; + TypeFqn = typeFqn; + Depth = depth; + RootOrigin = rootOrigin; + Origin = origin; + } + + public bool Equals(BaseTypeChainFocus other) => + string.Equals(TypeFqn, other.TypeFqn, StringComparison.Ordinal) + && Depth == other.Depth + && Nullable.Equals(RootOrigin, other.RootOrigin) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is BaseTypeChainFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = TypeFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(TypeFqn); + hash = (hash * 397) ^ Depth; + hash = (hash * 397) ^ (RootOrigin?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(BaseTypeChainFocus left, BaseTypeChainFocus right) => left.Equals(right); + + public static bool operator !=(BaseTypeChainFocus left, BaseTypeChainFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/BaseTypeChainFocusShapeExtensions.cs b/Scribe/Shapes/BaseTypeChainFocusShapeExtensions.cs new file mode 100644 index 0000000..89b716e --- /dev/null +++ b/Scribe/Shapes/BaseTypeChainFocusShapeExtensions.cs @@ -0,0 +1,42 @@ +using System; + +namespace Scribe.Shapes; + +/// +/// Fluent sub-lens entry points on . +/// Currently exposes — re-entering the type-shape world +/// rooted at a base-chain step so type-level lenses (attributes, members, +/// base-type-chain) can run against the base type itself. +/// +public static class BaseTypeChainFocusShapeExtensions +{ + /// + /// Enter a nested rooted at this base-chain + /// step. Always yields exactly one — the base type's + /// symbol, which is guaranteed to be an + /// . + /// + public static FocusShape AsTypeShape( + this FocusShape shape, + Action>? configure = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.BaseTypeChainAsTypeShape(); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: 0, + MaxCount: null, + Presence: null, + ParentOrigin: parent => parent.Origin)); + + return shape; + } +} diff --git a/Scribe/Shapes/ConstructorArgFocus.cs b/Scribe/Shapes/ConstructorArgFocus.cs new file mode 100644 index 0000000..0ff4705 --- /dev/null +++ b/Scribe/Shapes/ConstructorArgFocus.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for a positional constructor argument on an attribute +/// application. Produced by the ConstructorArg<T>(index) lens. +/// +/// The CLR type of the unwrapped argument value. Must be IEquatable-safe for cache correctness. +/// +/// +/// The lens produces a focus only when the argument at the given index can be +/// coerced to . Otherwise the lens yields no focus and +/// sub-predicates silently pass. +/// +/// +/// Identity breadcrumb is (OwnerFqn, Index, Value, Origin). Constant values +/// hash cheaply; is excluded from equality. +/// +/// +public readonly struct ConstructorArgFocus : IEquatable> +{ + /// The raw typed-constant from the attribute application. Excluded from equality. + public TypedConstant TypedConstant { get; } + + /// The coerced argument value. + public T? Value { get; } + + /// Zero-based index of the positional argument. + public int Index { get; } + + /// Owner breadcrumb: attribute class FQN plus the FQN of the symbol the attribute is applied to. + public string OwnerFqn { get; } + + /// Source span of the argument expression — the cache-stable breadcrumb. + public LocationInfo? Origin { get; } + + public ConstructorArgFocus( + TypedConstant typedConstant, + T? value, + int index, + string ownerFqn, + LocationInfo? origin) + { + TypedConstant = typedConstant; + Value = value; + Index = index; + OwnerFqn = ownerFqn; + Origin = origin; + } + + public bool Equals(ConstructorArgFocus other) => + EqualityComparer.Default.Equals(Value, other.Value) + && Index == other.Index + && string.Equals(OwnerFqn, other.OwnerFqn, StringComparison.Ordinal) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is ConstructorArgFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = Value is null ? 0 : EqualityComparer.Default.GetHashCode(Value); + hash = (hash * 397) ^ Index; + hash = (hash * 397) ^ (OwnerFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(OwnerFqn)); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(ConstructorArgFocus left, ConstructorArgFocus right) => left.Equals(right); + + public static bool operator !=(ConstructorArgFocus left, ConstructorArgFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/ConstructorArgFocusLeafPredicates.cs b/Scribe/Shapes/ConstructorArgFocusLeafPredicates.cs new file mode 100644 index 0000000..d034889 --- /dev/null +++ b/Scribe/Shapes/ConstructorArgFocusLeafPredicates.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Leaf-predicate extensions on . v1 +/// subset per the gap analysis B.4 catalogue — covers the "does this positional +/// attribute argument equal / satisfy …?" checks. +/// +public static class ConstructorArgFocusLeafPredicates +{ + /// Require the argument value to equal . + public static FocusShape> MustBe( + this FocusShape> shape, + T expected, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + var comparer = EqualityComparer.Default; + var expectedText = expected?.ToString() ?? "null"; + shape.AddCheck(new FocusCheck>( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE080"), + Title: spec?.Title ?? "Constructor argument must have required value", + MessageFormat: spec?.Message ?? "Constructor argument at position {0} must equal '{1}'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => comparer.Equals(focus.Value!, expected), + MessageArgs: focus => EquatableArray.Create(focus.Index.ToString(System.Globalization.CultureInfo.InvariantCulture), expectedText))); + return shape; + } + + /// + /// Require a string constructor argument to be non-null and non-empty. + /// Whitespace-only strings are considered empty. + /// + public static FocusShape> MustNotBeEmpty( + this FocusShape> shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck>( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE081"), + Title: spec?.Title ?? "Constructor argument must not be empty", + MessageFormat: spec?.Message ?? "Constructor argument at position {0} must not be empty", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => !string.IsNullOrWhiteSpace(focus.Value), + MessageArgs: static focus => EquatableArray.Create(focus.Index.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + return shape; + } +} diff --git a/Scribe/Shapes/DiagnosticSpec.cs b/Scribe/Shapes/DiagnosticSpec.cs index 5b8a363..72bce51 100644 --- a/Scribe/Shapes/DiagnosticSpec.cs +++ b/Scribe/Shapes/DiagnosticSpec.cs @@ -3,7 +3,7 @@ namespace Scribe.Shapes; /// -/// Selective override for a check's default diagnostic +/// Selective override for a check's default diagnostic /// descriptor. Any field left keeps the primitive's default. /// /// Diagnostic ID (defaults to SCRIBE-prefixed per primitive). diff --git a/Scribe/Shapes/FixSpec.cs b/Scribe/Shapes/FixSpec.cs index 14e1aa8..1d9c9db 100644 --- a/Scribe/Shapes/FixSpec.cs +++ b/Scribe/Shapes/FixSpec.cs @@ -2,7 +2,7 @@ namespace Scribe.Shapes; /// /// A declarative hint — picked up by Phase-4 code-fix generation — describing -/// how a violation of a check should be auto-repaired. +/// how a violation of a check should be auto-repaired. /// public readonly record struct FixSpec(FixKind Kind, string? EquivalenceKey = null); @@ -73,7 +73,7 @@ public enum FixKind /// /// User-supplied fix. The diagnostic carries a customFixTag property - /// that consumers register a handler for via the + /// that consumers register a handler for via the /// Ink extension WithCustomFix. The fix provider looks up the tag /// and dispatches to the user delegate instead of the built-in /// catalog. diff --git a/Scribe/Shapes/FocusCheck.cs b/Scribe/Shapes/FocusCheck.cs new file mode 100644 index 0000000..598e6d7 --- /dev/null +++ b/Scribe/Shapes/FocusCheck.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// A single declarative check registered on a . +/// Pairs a positive predicate ( = satisfied) with the +/// metadata needed to emit a diagnostic when violated. The focus itself is the +/// input — its cache-safe breadcrumb carries identity, and its embedded Roslyn +/// payload (symbol, attribute data, typed constant) is read from inside the +/// predicate body. +/// +internal sealed record FocusCheck( + string Id, + string Title, + string MessageFormat, + DiagnosticSeverity Severity, + Func Predicate, + Func> MessageArgs) + where TFocus : IEquatable; diff --git a/Scribe/Shapes/FocusShape.cs b/Scribe/Shapes/FocusShape.cs new file mode 100644 index 0000000..1ef5f19 --- /dev/null +++ b/Scribe/Shapes/FocusShape.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace Scribe.Shapes; + +/// +/// Authoring-time builder for a focus-stream chain. Parallel to +/// but parametrised by any cache-safe focus type +/// produced by a . Accumulates leaf checks +/// that apply at the focus and nested lens branches that navigate deeper. +/// +/// The focus this shape operates on. Must be equatable for cache correctness. +/// +/// +/// A is obtained by calling a lens method on +/// its parent authoring type — e.g. 's +/// Attributes(fqn) entry point returns a +/// . Further lens calls return +/// additional nested instances, one per hop. +/// +/// +/// Leaf checks and nested branches are accumulated in declaration order and +/// flattened into a single evaluation tree at +/// time. The parent walks the tree during +/// materialisation and emits for each +/// violation. +/// +/// +public sealed class FocusShape + where TFocus : IEquatable +{ + private readonly List> _checks = new(); + private readonly List> _branches = new(); + + internal FocusShape() + { + } + + internal IReadOnlyList> Checks => _checks; + internal IReadOnlyList> Branches => _branches; + + internal FocusShape AddCheck(FocusCheck check) + { + if (check is null) + { + throw new ArgumentNullException(nameof(check)); + } + + _checks.Add(check); + return this; + } + + internal FocusShape AddBranch(ILensBranch branch) + { + if (branch is null) + { + throw new ArgumentNullException(nameof(branch)); + } + + _branches.Add(branch); + return this; + } +} diff --git a/Scribe/Shapes/FocusShapeEscapeHatches.cs b/Scribe/Shapes/FocusShapeEscapeHatches.cs new file mode 100644 index 0000000..60e6be7 --- /dev/null +++ b/Scribe/Shapes/FocusShapeEscapeHatches.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Generic custom-predicate escape hatch on any . +/// Per the design principle "escape hatches as first-class citizens", every focus +/// shape exposes +/// at the same fluent position as its built-in predicates — so bolting on a custom +/// check feels native instead of dropping out of the DSL. +/// +public static class FocusShapeEscapeHatches +{ + /// + /// Register a custom positive predicate. means the + /// focus is satisfied; emits one diagnostic per + /// offending focus at the lens's smudge anchor. + /// + public static FocusShape Satisfy( + this FocusShape shape, + Func predicate, + string id, + string title, + string message, + DiagnosticSeverity severity = DiagnosticSeverity.Warning) + where TFocus : IEquatable + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + ValidateId(id); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(id), + Title: title, + MessageFormat: message, + Severity: severity, + Predicate: (focus, _, _) => predicate(focus), + MessageArgs: static _ => EquatableArray.Empty)); + return shape; + } + + /// + /// Richer overload whose predicate receives the and + /// . Use when the check needs + /// semantic-model access (e.g. looking up a type by metadata name) or must + /// cooperate with the incremental pipeline's cancellation. + /// + public static FocusShape Satisfy( + this FocusShape shape, + Func predicate, + string id, + string title, + string message, + DiagnosticSeverity severity = DiagnosticSeverity.Warning) + where TFocus : IEquatable + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + ValidateId(id); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(id), + Title: title, + MessageFormat: message, + Severity: severity, + Predicate: predicate, + MessageArgs: static _ => EquatableArray.Empty)); + return shape; + } + + private static void ValidateId(string id) + { + if (string.IsNullOrEmpty(id)) + { + throw new ArgumentException("Diagnostic id must not be empty.", nameof(id)); + } + } +} diff --git a/Scribe/Shapes/FocusSymbols.cs b/Scribe/Shapes/FocusSymbols.cs new file mode 100644 index 0000000..68bdcc4 --- /dev/null +++ b/Scribe/Shapes/FocusSymbols.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; + +namespace Scribe.Shapes; + +/// +/// B.7 — cross-focus symbol comparison helpers. Compares two foci without +/// leaking raw references into cached pipeline state: +/// the helpers run inside a live analyzer / generator pass where the symbol +/// reference is valid, and return a plain that is safe to +/// feed into downstream equality. +/// +/// +/// +/// Use inside lens-configure callbacks or Satisfy predicates where +/// an outer focus has been captured via closure, and you need to assert +/// that a navigated inner focus refers to the same underlying type. +/// The canonical example is the Event Contract cycle check: the +/// [Applies<E>] type argument on a handler must equal the +/// event E the outer chain navigated to. +/// +/// +/// All comparisons use over +/// — so open- and closed-generic +/// instantiations of the same definition compare equal. +/// +/// +public static class FocusSymbols +{ + /// + /// Compare two type-symbol references using . + /// Returns if either side is . + /// + public static bool SameOriginalDefinition(ISymbol? left, ISymbol? right) + { + if (left is null || right is null) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals( + left.OriginalDefinition, right.OriginalDefinition); + } + + /// + /// Direct comparison — stricter + /// than SameOriginalDefinition; a closed-generic List<int> + /// and List<string> compare unequal here but equal under + /// SameOriginalDefinition. + /// + public static bool SymbolEquals(ISymbol? left, ISymbol? right) + { + if (left is null || right is null) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(left, right); + } + + /// + /// Cross-focus wrapper: compare the underlying symbols of two + /// values by original definition. + /// + public static bool SameOriginalDefinition(this TypeFocus left, TypeFocus right) => + SameOriginalDefinition(left.Symbol, right.Symbol); + + /// + /// Cross-focus wrapper: compare a against a + /// by original definition. Typical use: assert + /// that a navigated generic type argument refers to the outer type focus. + /// + public static bool SameOriginalDefinition(this TypeFocus left, TypeArgFocus right) => + SameOriginalDefinition(left.Symbol, right.Symbol); + + /// Mirror of the (TypeFocus, TypeArgFocus) overload. + public static bool SameOriginalDefinition(this TypeArgFocus left, TypeFocus right) => + SameOriginalDefinition(left.Symbol, right.Symbol); + + /// Cross-focus wrapper over two values. + public static bool SameOriginalDefinition(this TypeArgFocus left, TypeArgFocus right) => + SameOriginalDefinition(left.Symbol, right.Symbol); +} diff --git a/Scribe/Shapes/ILensBranch.cs b/Scribe/Shapes/ILensBranch.cs new file mode 100644 index 0000000..06e6723 --- /dev/null +++ b/Scribe/Shapes/ILensBranch.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Non-generic tree node in a focus-stream evaluation plan. A parent +/// (or ) holds a list of +/// branches; each branch binds a to a nested +/// . At evaluation time, the branch navigates +/// the lens, applies per-child checks, and recurses into its own sub-branches. +/// +/// The focus type flowing into the lens. +internal interface ILensBranch +{ + /// + /// Evaluate this branch against the given parent focus, appending any + /// violations to . + /// + /// Parent focus flowing into the lens. + /// Current compilation. + /// Cancellation token. + /// Destination list for any produced violations. + /// + /// B.8 — accumulated focus-path breadcrumb from enclosing lenses. Branches + /// append their own HopDescription and stamp it onto every + /// DiagnosticInfo.FocusPath they produce. + /// at the root. + /// + void Evaluate( + TParent parent, + Compilation compilation, + CancellationToken ct, + List violations, + string? parentPath = null); + + /// + /// Emit every diagnostic descriptor this branch (and its nested subtree) can produce. + /// Used by to populate + /// . + /// + IEnumerable<(string Id, string Title, string MessageFormat, DiagnosticSeverity Severity)> CollectDescriptors(); +} + +/// +/// Concrete lens branch: navigate from to +/// via , then evaluate the +/// nested against each produced child. +/// +/// Navigation + smudge primitive. +/// Authoring-time shape describing checks on the child focus. +/// Minimum number of navigated children required. 0 disables the minimum check. +/// Maximum number of navigated children allowed. disables the maximum check. +/// Descriptor metadata for the presence-violation diagnostic emitted when / are breached. means no presence constraint. +/// Locator for the parent focus's span — used as the diagnostic target when the child set is empty and no child span exists to squiggle. +/// How nested-check results are aggregated across children. (default) preserves pre-quantifier behaviour: per-child violations flush directly to the parent list. +/// Descriptor metadata for the aggregate diagnostic emitted by / . Required when is not . +/// B.8 — human-readable name of this navigation step (e.g. Attributes("Foo")), folded into child DiagnosticInfo.FocusPath so violations show which lens hops produced them. omits this hop from the path. +internal sealed record LensBranch( + Lens Lens, + FocusShape Nested, + int MinCount, + int? MaxCount, + LensPresenceSpec? Presence, + Func ParentOrigin, + Quantifier Quantifier = Quantifier.All, + LensQuantifierSpec? QuantifierSpec = null, + string? HopDescription = null) : ILensBranch + where TChild : IEquatable +{ + public void Evaluate( + TParent parent, + Compilation compilation, + CancellationToken ct, + List violations, + string? parentPath = null) + { + ct.ThrowIfCancellationRequested(); + + var path = ComposePath(parentPath); + + var children = new List(); + foreach (var child in Lens.Navigate(parent)) + { + children.Add(child); + } + + if (Presence is { } presence) + { + var outOfRange = + children.Count < MinCount + || (MaxCount is { } max && children.Count > max); + if (outOfRange) + { + violations.Add(new DiagnosticInfo( + Id: presence.Id, + Severity: presence.Severity, + MessageArgs: EquatableArray.Create( + MinCount.ToString(System.Globalization.CultureInfo.InvariantCulture), + (MaxCount?.ToString(System.Globalization.CultureInfo.InvariantCulture)) ?? "\u221e", + children.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Location: ParentOrigin(parent), + FocusPath: path)); + } + } + + switch (Quantifier) + { + case Quantifier.All: + EvaluateAll(children, compilation, ct, violations, path); + break; + case Quantifier.Any: + EvaluateAny(parent, children, compilation, ct, violations, path); + break; + case Quantifier.None: + EvaluateNone(children, compilation, ct, violations, path); + break; + } + } + + private string? ComposePath(string? parentPath) + { + if (HopDescription is null) + { + return parentPath; + } + + return parentPath is null + ? HopDescription + : parentPath + " → " + HopDescription; + } + + private void EvaluateAll( + List children, + Compilation compilation, + CancellationToken ct, + List violations, + string? path) + { + foreach (var child in children) + { + ct.ThrowIfCancellationRequested(); + foreach (var check in Nested.Checks) + { + ct.ThrowIfCancellationRequested(); + if (check.Predicate(child, compilation, ct)) + { + continue; + } + + violations.Add(new DiagnosticInfo( + Id: check.Id, + Severity: check.Severity, + MessageArgs: check.MessageArgs(child), + Location: Lens.Smudge(child), + FocusPath: path)); + } + + foreach (var sub in Nested.Branches) + { + sub.Evaluate(child, compilation, ct, violations, path); + } + } + } + + private void EvaluateAny( + TParent parent, + List children, + Compilation compilation, + CancellationToken ct, + List violations, + string? path) + { + foreach (var child in children) + { + if (ChildPasses(child, compilation, ct, path)) + { + return; + } + } + + if (QuantifierSpec is { } spec) + { + violations.Add(new DiagnosticInfo( + Id: spec.Id, + Severity: spec.Severity, + MessageArgs: EquatableArray.Empty, + Location: ParentOrigin(parent), + FocusPath: path)); + } + } + + private void EvaluateNone( + List children, + Compilation compilation, + CancellationToken ct, + List violations, + string? path) + { + foreach (var child in children) + { + ct.ThrowIfCancellationRequested(); + if (!ChildPasses(child, compilation, ct, path)) + { + continue; + } + + if (QuantifierSpec is { } spec) + { + violations.Add(new DiagnosticInfo( + Id: spec.Id, + Severity: spec.Severity, + MessageArgs: EquatableArray.Empty, + Location: Lens.Smudge(child), + FocusPath: path)); + } + } + } + + private bool ChildPasses(TChild child, Compilation compilation, CancellationToken ct, string? path) + { + ct.ThrowIfCancellationRequested(); + foreach (var check in Nested.Checks) + { + if (!check.Predicate(child, compilation, ct)) + { + return false; + } + } + + if (Nested.Branches.Count == 0) + { + return true; + } + + var scratch = new List(); + foreach (var sub in Nested.Branches) + { + sub.Evaluate(child, compilation, ct, scratch, path); + if (scratch.Count > 0) + { + return false; + } + } + + return true; + } + + public IEnumerable<(string Id, string Title, string MessageFormat, DiagnosticSeverity Severity)> CollectDescriptors() + { + if (Presence is { } presence) + { + yield return (presence.Id, presence.Title, presence.MessageFormat, presence.Severity); + } + + if (QuantifierSpec is { } qspec) + { + yield return (qspec.Id, qspec.Title, qspec.MessageFormat, qspec.Severity); + } + + foreach (var check in Nested.Checks) + { + yield return (check.Id, check.Title, check.MessageFormat, check.Severity); + } + + foreach (var sub in Nested.Branches) + { + foreach (var descriptor in sub.CollectDescriptors()) + { + yield return descriptor; + } + } + } +} + +/// +/// Descriptor metadata for the diagnostic emitted when a lens branch's +/// min / max presence constraints are breached. Three message +/// slots: {0} = min, {1} = max (or ), {2} = observed. +/// +internal sealed record LensPresenceSpec( + string Id, + string Title, + string MessageFormat, + DiagnosticSeverity Severity); + +/// +/// Descriptor metadata for the aggregate diagnostic emitted by a lens branch +/// whose is +/// or . +/// No format slots — the message is a fixed string chosen at authoring time. +/// +internal sealed record LensQuantifierSpec( + string Id, + string Title, + string MessageFormat, + DiagnosticSeverity Severity); diff --git a/Scribe/Shapes/Lens.cs b/Scribe/Shapes/Lens.cs new file mode 100644 index 0000000..3af219e --- /dev/null +++ b/Scribe/Shapes/Lens.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Structural refocus primitive. A redirects +/// attention from one focus () to another +/// () along an edge the symbol graph already provides — +/// attributes, type arguments, constructor arguments, members, base types. +/// +/// The focus type the lens reads from. +/// The focus type the lens produces. +/// +/// +/// A Lens is a SelectMany: one source focus can produce zero or more +/// target foci. Lenses chain to reach deep positions, each refocusing the +/// view. A query like "for every type implementing X, for every +/// [Foo<T>] attribute, T must implement Y" is three Lens hops. +/// +/// +/// Every lens carries a Smudge — a location-propagation function that +/// resolves the new focus's source span so a violation reported three hops +/// deep squiggles at the right character, not at the root type. +/// +/// +/// Target focus types must themselves be equatable for the incremental cache +/// to stay correct. The lens does not enforce this — it is a discipline the +/// focus authors follow (see ). +/// +/// +public sealed class Lens +{ + /// + /// Navigate from one source focus to the set of target foci it refocuses to. + /// An empty result means "this lens does not apply here" — a zero-hit lens + /// silently passes through sub-predicates. + /// + public Func> Navigate { get; } + + /// + /// Resolve the source span of a target focus. Called when a violation reported + /// downstream needs to surface at the newly-focused position rather than at the + /// source focus. The returned is cache-safe and + /// equatable; raw Roslyn Location values must not be carried here. + /// + public Func Smudge { get; } + + public Lens( + Func> navigate, + Func smudge) + { + Navigate = navigate ?? throw new ArgumentNullException(nameof(navigate)); + Smudge = smudge ?? throw new ArgumentNullException(nameof(smudge)); + } + + /// + /// Compose this lens with a second lens to form a deeper hop. The resulting + /// lens's resolves at the deepest target — that is where + /// violations at the end of the chain should squiggle. + /// + public Lens Then(Lens next) + { + if (next is null) + { + throw new ArgumentNullException(nameof(next)); + } + + var outer = this; + return new Lens( + navigate: source => + { + var results = new List(); + foreach (var mid in outer.Navigate(source)) + { + foreach (var target in next.Navigate(mid)) + { + results.Add(target); + } + } + + return results; + }, + smudge: next.Smudge); + } +} diff --git a/Scribe/Shapes/Lenses/BuiltinLenses.cs b/Scribe/Shapes/Lenses/BuiltinLenses.cs new file mode 100644 index 0000000..d1cfcb6 --- /dev/null +++ b/Scribe/Shapes/Lenses/BuiltinLenses.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes.Lenses; + +/// +/// Static factory methods for every built-in lens shipped with Scribe. Each factory +/// returns a whose +/// enumerates children of the source focus in source order and whose +/// resolves the child's declaration span. +/// +internal static class BuiltinLenses +{ + /// + /// TypeFocus → AttributeFocus. Navigates every attribute application on the + /// type whose class FQN equals (open-generic forms + /// are matched by the bare name before <). Per-usage index is carried on + /// the focus to disambiguate multiple applications of the same attribute class. + /// + public static Lens Attributes(string attributeFqn) + { + if (string.IsNullOrEmpty(attributeFqn)) + { + throw new ArgumentException("Attribute FQN must not be empty.", nameof(attributeFqn)); + } + + var interned = InternPool.Intern(attributeFqn); + return new Lens( + navigate: parent => NavigateAttributes(parent, interned), + smudge: focus => focus.Origin); + } + + private static IEnumerable NavigateAttributes(TypeFocus parent, string attributeFqn) + { + var ownerFqn = parent.Fqn; + var attributes = parent.Symbol.GetAttributes(); + var index = 0; + foreach (var attribute in attributes) + { + var cls = attribute.AttributeClass; + if (cls is null) + { + continue; + } + + var displayed = cls.ToDisplayString(); + if (!MatchesAttributeFqn(displayed, attributeFqn)) + { + continue; + } + + var syntaxRef = attribute.ApplicationSyntaxReference; + var origin = syntaxRef is null + ? (LocationInfo?)null + : LocationInfo.From(Location.Create(syntaxRef.SyntaxTree, syntaxRef.Span)); + + yield return new AttributeFocus( + data: attribute, + attributeFqn: InternPool.Intern(displayed), + ownerFqn: ownerFqn, + index: index, + origin: origin); + + index++; + } + } + + private static bool MatchesAttributeFqn(string displayed, string fqn) + { + if (string.Equals(displayed, fqn, StringComparison.Ordinal)) + { + return true; + } + + var bracket = displayed.IndexOf('<'); + if (bracket <= 0) + { + return false; + } + + return string.Equals(displayed.Substring(0, bracket), fqn, StringComparison.Ordinal); + } + + /// + /// TypeFocus → MemberFocus. Navigates every directly-declared member of + /// the type in source order, optionally filtered by + /// . Implicit and synthesized + /// members are excluded; inherited members are not visited. Smudge resolves + /// the member's declaration span. + /// + public static Lens Members(SymbolKind? kindFilter = null) => + new Lens( + navigate: parent => NavigateMembers(parent, kindFilter), + smudge: focus => focus.Origin); + + private static IEnumerable NavigateMembers(TypeFocus parent, SymbolKind? kindFilter) + { + var ownerFqn = parent.Fqn; + var members = new List(); + foreach (var member in parent.Symbol.GetMembers()) + { + if (member.IsImplicitlyDeclared) + { + continue; + } + + if (member.DeclaringSyntaxReferences.Length == 0) + { + continue; + } + + if (kindFilter is { } kind && member.Kind != kind) + { + continue; + } + + members.Add(member); + } + + members.Sort(DeclarationSpanComparer.Instance); + + foreach (var member in members) + { + var syntaxRef = member.DeclaringSyntaxReferences[0]; + var origin = LocationInfo.From( + Location.Create(syntaxRef.SyntaxTree, syntaxRef.Span)); + + yield return new MemberFocus( + symbol: member, + memberFqn: InternPool.Intern(member.ToDisplayString()), + ownerFqn: ownerFqn, + kind: member.Kind, + origin: origin); + } + } + + /// + /// TypeFocus → BaseTypeChainFocus. Navigates the type's inheritance chain + /// from the immediate base up to (but excluding) . Each step + /// carries the depth above the starting type. Interfaces are not included — + /// use the interface-specific lens (or + /// ) for interface navigation. + /// Smudge resolves to the base type's own declaration span. + /// + public static Lens BaseTypeChain() => + new Lens( + navigate: NavigateBaseTypeChain, + smudge: focus => focus.Origin); + + private static IEnumerable NavigateBaseTypeChain(TypeFocus parent) + { + var rootOrigin = parent.Origin; + var current = parent.Symbol.BaseType; + var depth = 0; + while (current is not null && current.SpecialType != SpecialType.System_Object) + { + var origin = FirstDeclarationOrigin(current); + yield return new BaseTypeChainFocus( + symbol: current, + typeFqn: InternPool.Intern(current.ToDisplayString()), + depth: depth, + rootOrigin: rootOrigin, + origin: origin); + + current = current.BaseType; + depth++; + } + } + + private static LocationInfo? FirstDeclarationOrigin(ISymbol symbol) + { + var refs = symbol.DeclaringSyntaxReferences; + if (refs.Length == 0) + { + var locations = symbol.Locations; + return locations.Length == 0 + ? null + : LocationInfo.From(locations[0]); + } + + var syntaxRef = refs[0]; + return LocationInfo.From(Location.Create(syntaxRef.SyntaxTree, syntaxRef.Span)); + } + + /// + /// AttributeFocus → TypeArgFocus. Navigates the type argument at the given + /// position in the attribute's generic type argument list — e.g. index 0 + /// of [Foo<string>] yields a focus carrying string. Yields no + /// focus when the attribute has no generics or is out + /// of range. + /// + public static Lens GenericTypeArg(int index) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), "Index must be non-negative."); + } + + return new Lens( + navigate: parent => NavigateGenericTypeArg(parent, index), + smudge: focus => focus.Origin); + } + + private static IEnumerable NavigateGenericTypeArg(AttributeFocus parent, int index) + { + var cls = parent.Data.AttributeClass; + if (cls is null) + { + yield break; + } + + var args = cls.TypeArguments; + if (index >= args.Length) + { + yield break; + } + + var arg = args[index]; + yield return new TypeArgFocus( + symbol: arg, + typeFqn: InternPool.Intern(arg.ToDisplayString()), + index: index, + parentOrigin: parent.Origin, + origin: parent.Origin); + } + + /// + /// AttributeFocus → ConstructorArgFocus<T>. Navigates the positional + /// constructor argument at the given index, coercing its value to + /// . Yields no focus when the index is out of range or + /// the value cannot be coerced. + /// + public static Lens> ConstructorArg(int index) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), "Index must be non-negative."); + } + + return new Lens>( + navigate: parent => NavigateConstructorArg(parent, index), + smudge: focus => focus.Origin); + } + + private static IEnumerable> NavigateConstructorArg(AttributeFocus parent, int index) + { + var args = parent.Data.ConstructorArguments; + if (index >= args.Length) + { + yield break; + } + + var tc = args[index]; + if (!TryCoerce(tc, out var value)) + { + yield break; + } + + var ownerBreadcrumb = InternPool.Intern(parent.AttributeFqn + ">" + parent.OwnerFqn); + yield return new ConstructorArgFocus( + typedConstant: tc, + value: value, + index: index, + ownerFqn: ownerBreadcrumb, + origin: parent.Origin); + } + + /// + /// AttributeFocus → NamedArgFocus<T>. Navigates the named argument + /// on the attribute application, coercing its value to + /// . Yields no focus when no argument matches the name + /// or the value cannot be coerced. + /// + public static Lens> NamedArg(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Named argument name must not be empty.", nameof(name)); + } + + return new Lens>( + navigate: parent => NavigateNamedArg(parent, name), + smudge: focus => focus.Origin); + } + + private static IEnumerable> NavigateNamedArg(AttributeFocus parent, string name) + { + foreach (var kvp in parent.Data.NamedArguments) + { + if (!string.Equals(kvp.Key, name, StringComparison.Ordinal)) + { + continue; + } + + if (!TryCoerce(kvp.Value, out var value)) + { + yield break; + } + + var ownerBreadcrumb = InternPool.Intern(parent.AttributeFqn + ">" + parent.OwnerFqn); + yield return new NamedArgFocus( + typedConstant: kvp.Value, + value: value, + name: name, + ownerFqn: ownerBreadcrumb, + origin: parent.Origin); + yield break; + } + } + + /// + /// TypeArgFocus → TypeFocus. Re-enters the type-shape world rooted at + /// the type argument, enabling reuse of type-level lenses under a generic + /// argument. Yields no focus when the argument's symbol is not an + /// (e.g. type parameters, arrays, pointers). + /// + public static Lens TypeArgAsTypeShape() => + new Lens( + navigate: NavigateTypeArgAsTypeShape, + smudge: focus => focus.Origin); + + private static IEnumerable NavigateTypeArgAsTypeShape(TypeArgFocus parent) + { + if (parent.Symbol is not INamedTypeSymbol named) + { + yield break; + } + + yield return new TypeFocus( + symbol: named, + fqn: InternPool.Intern(named.ToDisplayString()), + origin: FirstDeclarationOrigin(named) ?? parent.Origin); + } + + /// + /// BaseTypeChainFocus → TypeFocus. Re-enters the type-shape world rooted + /// at a base-chain step, enabling type-level lenses to run on the base type. + /// + public static Lens BaseTypeChainAsTypeShape() => + new Lens( + navigate: NavigateBaseTypeChainAsTypeShape, + smudge: focus => focus.Origin); + + private static IEnumerable NavigateBaseTypeChainAsTypeShape(BaseTypeChainFocus parent) + { + yield return new TypeFocus( + symbol: parent.Symbol, + fqn: parent.TypeFqn, + origin: parent.Origin); + } + + private static bool TryCoerce(TypedConstant tc, out T? value) + { + if (tc.IsNull) + { + value = default; + return !typeof(T).IsValueType + || Nullable.GetUnderlyingType(typeof(T)) is not null; + } + + if (tc.Value is T coerced) + { + value = coerced; + return true; + } + + // ITypeSymbol-valued constants (e.g. typeof(X)) carry ITypeSymbol values; if + // the caller asked for T = string we unwrap to its display form. + if (typeof(T) == typeof(string) && tc.Value is ITypeSymbol ts) + { + value = (T)(object)ts.ToDisplayString(); + return true; + } + + value = default; + return false; + } + + private sealed class DeclarationSpanComparer : IComparer + { + public static readonly DeclarationSpanComparer Instance = new(); + + public int Compare(ISymbol? x, ISymbol? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var xRef = x.DeclaringSyntaxReferences[0]; + var yRef = y.DeclaringSyntaxReferences[0]; + var fileCmp = string.CompareOrdinal(xRef.SyntaxTree.FilePath, yRef.SyntaxTree.FilePath); + return fileCmp != 0 ? fileCmp : xRef.Span.Start.CompareTo(yRef.Span.Start); + } + } +} diff --git a/Scribe/Shapes/MemberCheck.cs b/Scribe/Shapes/MemberCheck.cs index 61becb8..14c53a2 100644 --- a/Scribe/Shapes/MemberCheck.cs +++ b/Scribe/Shapes/MemberCheck.cs @@ -6,7 +6,7 @@ namespace Scribe.Shapes; /// -/// Internal representation of a member-level check on a . +/// Internal representation of a member-level check on a . /// Runs per declared member of each matched type; emits one diagnostic per /// member that satisfies . /// diff --git a/Scribe/Shapes/MemberDiagnosticSpec.cs b/Scribe/Shapes/MemberDiagnosticSpec.cs index 70b0305..b418eb0 100644 --- a/Scribe/Shapes/MemberDiagnosticSpec.cs +++ b/Scribe/Shapes/MemberDiagnosticSpec.cs @@ -4,7 +4,7 @@ namespace Scribe.Shapes; /// /// Declarative descriptor for a member-level check registered via -/// . Unlike +/// . Unlike /// (which overrides a primitive's built-in defaults) this spec is fully /// user-defined — , , and /// are required. diff --git a/Scribe/Shapes/MemberFocus.cs b/Scribe/Shapes/MemberFocus.cs new file mode 100644 index 0000000..4d13ca1 --- /dev/null +++ b/Scribe/Shapes/MemberFocus.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for a member of a type — field, property, method, or event. +/// Produced by the Members(filter?) lens. +/// +/// +/// Identity breadcrumb is (MemberFqn, OwnerFqn, Origin). The member kind is +/// carried for filter convenience but not part of equality — the FQN already +/// discriminates members of different kinds. +/// is excluded from equality. +/// +public readonly struct MemberFocus : IEquatable +{ + /// The member symbol. Excluded from equality — do not use for caching. + public ISymbol Symbol { get; } + + /// Fully-qualified name of the member (interned). + public string MemberFqn { get; } + + /// Fully-qualified name of the containing type. + public string OwnerFqn { get; } + + /// The member's — field, property, method, or event. Carried for filter convenience, not part of equality. + public SymbolKind Kind { get; } + + /// Source span of the member declaration — the cache-stable breadcrumb. + public LocationInfo? Origin { get; } + + public MemberFocus( + ISymbol symbol, + string memberFqn, + string ownerFqn, + SymbolKind kind, + LocationInfo? origin) + { + Symbol = symbol; + MemberFqn = memberFqn; + OwnerFqn = ownerFqn; + Kind = kind; + Origin = origin; + } + + public bool Equals(MemberFocus other) => + string.Equals(MemberFqn, other.MemberFqn, StringComparison.Ordinal) + && string.Equals(OwnerFqn, other.OwnerFqn, StringComparison.Ordinal) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is MemberFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = MemberFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(MemberFqn); + hash = (hash * 397) ^ (OwnerFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(OwnerFqn)); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(MemberFocus left, MemberFocus right) => left.Equals(right); + + public static bool operator !=(MemberFocus left, MemberFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/MemberFocusLeafPredicates.cs b/Scribe/Shapes/MemberFocusLeafPredicates.cs new file mode 100644 index 0000000..51ecd5d --- /dev/null +++ b/Scribe/Shapes/MemberFocusLeafPredicates.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Leaf-predicate extensions on . v1 subset +/// per the gap analysis B.4 catalogue — covers the most common member-level +/// checks an author wants to declare under a Members(...) lens. +/// +public static class MemberFocusLeafPredicates +{ + /// Require the member to carry the attribute named by . + public static FocusShape MustHaveAttribute( + this FocusShape shape, + string metadataName, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE060"), + Title: spec?.Title ?? "Member must have required attribute", + MessageFormat: spec?.Message ?? "Member '{0}' must be annotated with '[{1}]'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => HasAttribute(focus.Symbol, interned), + MessageArgs: focus => EquatableArray.Create(focus.Symbol.Name, interned))); + return shape; + } + + /// Require the member's declared accessibility to be public. + public static FocusShape MustBePublic( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE061"), + Title: spec?.Title ?? "Member must be public", + MessageFormat: spec?.Message ?? "Member '{0}' must be declared 'public'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => focus.Symbol.DeclaredAccessibility == Accessibility.Public, + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// Require the member to be declared static. + public static FocusShape MustBeStatic( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE062"), + Title: spec?.Title ?? "Member must be static", + MessageFormat: spec?.Message ?? "Member '{0}' must be declared 'static'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => focus.Symbol.IsStatic, + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// + /// Require the member to be read-only. Applies to fields (readonly), + /// properties (no setter, or init-only), and auto-implemented properties backed by a + /// readonly field. Members that cannot meaningfully be read-only (methods, events) + /// pass silently. + /// + public static FocusShape MustBeReadOnly( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE063"), + Title: spec?.Title ?? "Member must be read-only", + MessageFormat: spec?.Message ?? "Member '{0}' must be read-only", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => IsReadOnly(focus.Symbol), + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + private static bool IsReadOnly(ISymbol symbol) => + symbol switch + { + IFieldSymbol f => f.IsReadOnly || f.IsConst, + IPropertySymbol p => p.SetMethod is null || p.SetMethod.IsInitOnly, + _ => true, + }; + + private static bool HasAttribute(ISymbol symbol, string metadataName) + { + foreach (var attribute in symbol.GetAttributes()) + { + var fqn = attribute.AttributeClass?.ToDisplayString(); + if (fqn is null) + { + continue; + } + + if (string.Equals(fqn, metadataName, StringComparison.Ordinal)) + { + return true; + } + + var bracket = fqn.IndexOf('<'); + var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn; + if (string.Equals(bare, metadataName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/Scribe/Shapes/NamedArgFocus.cs b/Scribe/Shapes/NamedArgFocus.cs new file mode 100644 index 0000000..489273c --- /dev/null +++ b/Scribe/Shapes/NamedArgFocus.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for a named argument on an attribute application — the +/// Bar = 42 in [Foo(Bar = 42)]. Produced by the +/// NamedArg<T>(name) lens. +/// +/// The CLR type of the unwrapped argument value. Must be IEquatable-safe for cache correctness. +/// +/// Identity breadcrumb is (OwnerFqn, Name, Value, Origin). The raw +/// is carried for in-flight use but excluded from equality. +/// +public readonly struct NamedArgFocus : IEquatable> +{ + /// The raw typed-constant from the attribute application. Excluded from equality. + public TypedConstant TypedConstant { get; } + + /// The coerced argument value. + public T? Value { get; } + + /// The argument name as written at the attribute site. + public string Name { get; } + + /// Owner breadcrumb: attribute class FQN plus the FQN of the symbol the attribute is applied to. + public string OwnerFqn { get; } + + /// Source span of the name = value argument — the cache-stable breadcrumb. + public LocationInfo? Origin { get; } + + public NamedArgFocus( + TypedConstant typedConstant, + T? value, + string name, + string ownerFqn, + LocationInfo? origin) + { + TypedConstant = typedConstant; + Value = value; + Name = name; + OwnerFqn = ownerFqn; + Origin = origin; + } + + public bool Equals(NamedArgFocus other) => + EqualityComparer.Default.Equals(Value, other.Value) + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && string.Equals(OwnerFqn, other.OwnerFqn, StringComparison.Ordinal) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is NamedArgFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = Value is null ? 0 : EqualityComparer.Default.GetHashCode(Value); + hash = (hash * 397) ^ (Name is null ? 0 : StringComparer.Ordinal.GetHashCode(Name)); + hash = (hash * 397) ^ (OwnerFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(OwnerFqn)); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(NamedArgFocus left, NamedArgFocus right) => left.Equals(right); + + public static bool operator !=(NamedArgFocus left, NamedArgFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/NamedArgFocusLeafPredicates.cs b/Scribe/Shapes/NamedArgFocusLeafPredicates.cs new file mode 100644 index 0000000..e80af15 --- /dev/null +++ b/Scribe/Shapes/NamedArgFocusLeafPredicates.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Leaf-predicate extensions on . v1 subset +/// per the gap analysis B.4 catalogue — covers the "does this named attribute +/// argument equal / satisfy …?" checks. Mirrors +/// . +/// +public static class NamedArgFocusLeafPredicates +{ + /// Require the named argument value to equal . + public static FocusShape> MustBe( + this FocusShape> shape, + T expected, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + var comparer = EqualityComparer.Default; + var expectedText = expected?.ToString() ?? "null"; + shape.AddCheck(new FocusCheck>( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE085"), + Title: spec?.Title ?? "Named argument must have required value", + MessageFormat: spec?.Message ?? "Named argument '{0}' must equal '{1}'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => comparer.Equals(focus.Value!, expected), + MessageArgs: focus => EquatableArray.Create(focus.Name, expectedText))); + return shape; + } + + /// + /// Require a string named argument to be non-null and non-empty. + /// Whitespace-only strings are considered empty. + /// + public static FocusShape> MustNotBeEmpty( + this FocusShape> shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck>( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE086"), + Title: spec?.Title ?? "Named argument must not be empty", + MessageFormat: spec?.Message ?? "Named argument '{0}' must not be empty", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => !string.IsNullOrWhiteSpace(focus.Value), + MessageArgs: static focus => EquatableArray.Create(focus.Name))); + return shape; + } +} diff --git a/Scribe/Shapes/OneOfTypeShape.cs b/Scribe/Shapes/OneOfTypeShape.cs new file mode 100644 index 0000000..2b496a9 --- /dev/null +++ b/Scribe/Shapes/OneOfTypeShape.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Terminal authoring node representing a disjunction of two or more +/// alternatives. The resulting +/// passes a given symbol iff at least one alternative's check tree +/// (kind filter, leaf checks, lens branches) produces zero violations against +/// it. When every alternative fails, a single fused diagnostic is emitted at +/// the symbol's primary location listing each branch's unsatisfied checks. +/// +/// +/// +/// Obtained from or +/// . Only +/// is exposed: an already-built disjunction cannot accumulate further +/// lenses or checks, since those would be ambiguous across branches. To +/// add more constraints, add them to the alternative(s) before calling +/// OneOf. +/// +/// +/// v1 constraints. All alternatives must share the same primary +/// attribute-driving metadata name (or none). Member-check alternatives +/// are rejected for v1 — those live on the alternative's +/// instances but are not re-entered by the fused +/// branch runner. Both restrictions can be lifted in follow-up work. +/// +/// +public sealed class OneOfTypeShape +{ + private readonly TypeShape[] _alternatives; + private readonly DiagnosticSpec? _fusionSpec; + + internal OneOfTypeShape(TypeShape[] alternatives, DiagnosticSpec? fusionSpec) + { + if (alternatives is null) + { + throw new ArgumentNullException(nameof(alternatives)); + } + + if (alternatives.Length < 2) + { + throw new ArgumentException("OneOf requires at least two alternatives.", nameof(alternatives)); + } + + foreach (var alt in alternatives) + { + if (alt is null) + { + throw new ArgumentException("OneOf alternatives must be non-null.", nameof(alternatives)); + } + + if (alt.MemberChecks.Count > 0) + { + throw new ArgumentException( + "OneOf alternatives cannot declare member checks (v1 restriction). " + + "Move the check onto a Members(...) lens branch instead.", + nameof(alternatives)); + } + } + + var primaryAttr = alternatives[0].PrimaryAttributeMetadataName; + var primaryIface = alternatives[0].PrimaryInterfaceMetadataName; + foreach (var alt in alternatives) + { + if (!string.Equals(alt.PrimaryAttributeMetadataName, primaryAttr, StringComparison.Ordinal)) + { + throw new ArgumentException( + "All OneOf alternatives must share the same primary attribute driver (or have none).", + nameof(alternatives)); + } + + if (!string.Equals(alt.PrimaryInterfaceMetadataName, primaryIface, StringComparison.Ordinal)) + { + throw new ArgumentException( + "All OneOf alternatives must share the same primary interface driver (or have none).", + nameof(alternatives)); + } + } + + _alternatives = alternatives; + _fusionSpec = fusionSpec; + } + + internal IReadOnlyList Alternatives => _alternatives; + + internal DiagnosticSpec? FusionSpec => _fusionSpec; + + /// + /// Etch the disjunction into a sealed . The + /// callback runs on any symbol matched by at least + /// one alternative's kind filter and driving attribute/interface. + /// + public Shape Etch(EtchDelegate etch) + where TModel : IEquatable + { + if (etch is null) + { + throw new ArgumentNullException(nameof(etch)); + } + + var branches = _alternatives.Select(a => new OneOfBranch( + Kind: a.Kind, + Checks: a.Checks.ToArray(), + LensBranches: a.LensBranches.ToArray())) + .ToArray(); + + var fusion = new LensQuantifierSpec( + Id: InternPool.Intern(_fusionSpec?.Id ?? "SCRIBE100"), + Title: _fusionSpec?.Title ?? "Disjunction (OneOf) — no alternative satisfied", + MessageFormat: _fusionSpec?.Message ?? "None of the {0} alternatives passed: {1}", + Severity: _fusionSpec?.Severity ?? DiagnosticSeverity.Error); + + return new Shape( + alternatives: branches, + primaryAttributeMetadataName: _alternatives[0].PrimaryAttributeMetadataName, + primaryInterfaceMetadataName: _alternatives[0].PrimaryInterfaceMetadataName, + fusionSpec: fusion, + etch: etch); + } +} + +/// +/// Flattened per-alternative payload used by the OneOf evaluator. Captures the +/// alternative's kind filter and its full check tree so each branch can be +/// evaluated independently against a candidate symbol. +/// +internal sealed record OneOfBranch( + TypeKindFilter Kind, + ShapeCheck[] Checks, + ILensBranch[] LensBranches); diff --git a/Scribe/Shapes/Relation.cs b/Scribe/Shapes/Prism.cs similarity index 88% rename from Scribe/Shapes/Relation.cs rename to Scribe/Shapes/Prism.cs index d8775f8..3f7f231 100644 --- a/Scribe/Shapes/Relation.cs +++ b/Scribe/Shapes/Prism.cs @@ -7,20 +7,22 @@ namespace Scribe.Shapes; /// -/// Join two shape streams on a key. Produces a matched-pair stream plus an -/// opt-in diagnostic stream for orphaned sides. Orphans are silent by default — -/// the pair is a query, not a requirement — so the author must declare -/// what missing means by calling -/// or . +/// Keyed refocus across two shape streams. A Prism combines or separates streams +/// by matching on a computed key — custom-cut optics for cross-stream joins where +/// no structural edge exists. Produces a matched stream plus an opt-in diagnostic +/// stream for orphaned sides. Orphans are silent by default — the prism is a +/// query, not a requirement — so the author declares what "missing" means by +/// calling or +/// . /// -public static class Relation +public static class Prism { /// /// Join and by equal keys. - /// The returned builder lazily wires matched-pair and diagnostic providers - /// when their properties are first accessed. + /// The returned builder lazily wires matched and diagnostic providers when + /// their properties are first accessed. /// - public static PairBuilder Pair( + public static PrismBuilder By( IncrementalValuesProvider> left, IncrementalValuesProvider> right, Func leftKey, @@ -31,10 +33,10 @@ public static PairBuilder Pair( } /// -/// Configuration surface for a join. +/// Configuration surface for a join. /// Each orphan policy returns this so the declaration reads left-to-right. /// -public sealed class PairBuilder +public sealed class PrismBuilder where TLeft : IEquatable where TRight : IEquatable { @@ -46,7 +48,7 @@ public sealed class PairBuilder private OrphanPolicy? _leftHasRight; private OrphanPolicy? _rightUnused; - internal PairBuilder( + internal PrismBuilder( IncrementalValuesProvider> left, IncrementalValuesProvider> right, Func leftKey, @@ -62,7 +64,7 @@ internal PairBuilder( /// Emit a diagnostic for every left-side item whose key matches no right-side /// item. The message receives {0} = left.Fqn and {1} = missing key. /// - public PairBuilder RequireLeftHasRight( + public PrismBuilder RequireLeftHasRight( string id, string title, string messageFormat, @@ -76,7 +78,7 @@ public PairBuilder RequireLeftHasRight( /// Emit a diagnostic for every right-side item whose key is not referenced by /// any left-side item. The message receives {0} = right.Fqn. /// - public PairBuilder WarnOnRightUnused( + public PrismBuilder WarnOnRightUnused( string id, string title, string messageFormat, @@ -91,7 +93,7 @@ public PairBuilder WarnOnRightUnused( /// right-side item sharing its key; duplicate right-side keys drop to first- /// wins in v1 (a RequireUniqueRightKey overload may be added later). /// - public IncrementalValuesProvider> Matched + public IncrementalValuesProvider> Matched { get { @@ -206,7 +208,7 @@ private static DiagnosticDescriptor BuildDescriptor(OrphanPolicy policy) => id: policy.Id, title: policy.Title, messageFormat: policy.MessageFormat, - category: "Scribe.Relation", + category: "Scribe.Prism", defaultSeverity: policy.Severity, isEnabledByDefault: true); @@ -266,7 +268,7 @@ private static ImmutableHashSet BuildLeftKeySet( return builder.ToImmutable(); } - private static ShapedPair? TryPair( + private static ShapedPrism? TryPair( ShapedSymbol left, ImmutableDictionary> rightByKey, Func leftKey) @@ -278,7 +280,7 @@ private static ImmutableHashSet BuildLeftKeySet( } return rightByKey.TryGetValue(key, out var right) - ? new ShapedPair(left, right) + ? new ShapedPrism(left, right) : null; } diff --git a/Scribe/Shapes/Quantifier.cs b/Scribe/Shapes/Quantifier.cs new file mode 100644 index 0000000..48d4d30 --- /dev/null +++ b/Scribe/Shapes/Quantifier.cs @@ -0,0 +1,43 @@ +namespace Scribe.Shapes; + +/// +/// How a lens branch aggregates the pass/fail result of its nested sub-shape +/// across the set of navigated children. Chooses between three classic +/// higher-order quantifiers. +/// +/// +/// +/// "A child passes" is defined as: every nested leaf predicate +/// returns and every nested sub-lens branch +/// produces zero violations when evaluated against that child. +/// +/// +/// is the default — the behaviour every lens branch had +/// before quantifiers existed — and is usually the implicit semantic of a +/// chained lens. / exist for set-level +/// checks the fluent-chain form cannot express. +/// +/// +public enum Quantifier +{ + /// + /// Every navigated child must pass. Violations emit per (child, check) + /// and land at each offending child's smudge anchor. Default. + /// + All = 0, + + /// + /// At least one navigated child must pass. Per-child violations are + /// suppressed; when zero children pass, one aggregate diagnostic is emitted + /// at the parent focus's span. + /// + Any = 1, + + /// + /// No navigated child may pass. Per-child "violations" (which here would + /// indicate the absence of a match) are suppressed; when one or more + /// children pass, one aggregate diagnostic is emitted at each offending + /// child's smudge anchor. + /// + None = 2, +} diff --git a/Scribe/Shapes/Shape.cs b/Scribe/Shapes/Shape.cs deleted file mode 100644 index bce4d23..0000000 --- a/Scribe/Shapes/Shape.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Scribe.Shapes; - -/// -/// Entry point for the Shape DSL. Pick the type-kind you want to match, then fluently -/// layer MustBeX constraints, then -/// into a cache-safe model. -/// -/// -/// -/// var shape = Shape.Class() -/// .MustHaveAttribute<ThingAttribute>() -/// .MustBePartial() -/// .MustImplement<IThing>() -/// .Project(in ctx => new ThingModel( -/// Fqn: ctx.Fqn, -/// Flavor: ctx.Attribute.Ctor<string>(0) ?? "default")); -/// -/// -public static class Shape -{ - /// Match class declarations. - public static ShapeBuilder Class() => new(TypeKindFilter.Class); - - /// Match record declarations (class form). - public static ShapeBuilder Record() => new(TypeKindFilter.Record); - - /// Match record struct declarations. - public static ShapeBuilder RecordStruct() => new(TypeKindFilter.RecordStruct); - - /// Match struct declarations. - public static ShapeBuilder Struct() => new(TypeKindFilter.Struct); - - /// Match interface declarations. - public static ShapeBuilder Interface() => new(TypeKindFilter.Interface); - - /// Match any type declaration regardless of kind. - public static ShapeBuilder AnyType() => new(TypeKindFilter.Any); -} diff --git a/Scribe/Shapes/ShapeCheck.cs b/Scribe/Shapes/ShapeCheck.cs index 5959baa..dc2375a 100644 --- a/Scribe/Shapes/ShapeCheck.cs +++ b/Scribe/Shapes/ShapeCheck.cs @@ -8,7 +8,7 @@ namespace Scribe.Shapes; /// /// Internal, opaque representation of a single declarative check registered on a -/// . A check pairs a positive +/// . A check pairs a positive /// ( = shape satisfied) with the metadata needed to emit a /// diagnostic when violated and the data needed to apply an automatic code fix. /// @@ -19,6 +19,6 @@ internal sealed record ShapeCheck( DiagnosticSeverity Severity, SquiggleAt SquiggleAt, FixKind FixKind, - Func Predicate, - Func> MessageArgs, - Func>? FixProperties = null); + Func Predicate, + Func> MessageArgs, + Func>? FixProperties = null); diff --git a/Scribe/Shapes/ShapeProjectionContext.cs b/Scribe/Shapes/ShapeEtchContext.cs similarity index 72% rename from Scribe/Shapes/ShapeProjectionContext.cs rename to Scribe/Shapes/ShapeEtchContext.cs index e5e6d97..d76dddd 100644 --- a/Scribe/Shapes/ShapeProjectionContext.cs +++ b/Scribe/Shapes/ShapeEtchContext.cs @@ -5,17 +5,17 @@ namespace Scribe.Shapes; /// -/// Stack-only context passed to the user projection callback of -/// . Exposes the matched symbol and the -/// attribute reader (if declared +/// Stack-only context passed to the user etch callback of +/// . Exposes the matched symbol and the +/// attribute reader (if declared /// the driving attribute). /// /// /// This is a ref struct: it pins and must not -/// escape the projection. Use it only to extract the cache-safe values that will +/// escape the etch callback. Use it only to extract the cache-safe values that will /// live in the resulting TModel. /// -public readonly ref struct ShapeProjectionContext +public readonly ref struct ShapeEtchContext { private readonly INamedTypeSymbol _symbol; private readonly AttributeReader _attribute; @@ -23,7 +23,7 @@ public readonly ref struct ShapeProjectionContext private readonly Compilation _compilation; private readonly CancellationToken _cancellationToken; - internal ShapeProjectionContext( + internal ShapeEtchContext( INamedTypeSymbol symbol, AttributeReader attribute, SemanticModel semanticModel, @@ -40,7 +40,7 @@ internal ShapeProjectionContext( /// The matched type symbol. public INamedTypeSymbol Symbol => _symbol; - /// Reader over the driving attribute, if was declared. + /// Reader over the driving attribute, if was declared. public AttributeReader Attribute => _attribute; /// Semantic model of the declaration's syntax tree. @@ -56,5 +56,5 @@ internal ShapeProjectionContext( public string Fqn => _symbol.ToDisplayString(); } -/// Projection delegate for . -public delegate TModel ProjectionDelegate(in ShapeProjectionContext ctx); +/// Etch delegate for . +public delegate TModel EtchDelegate(in ShapeEtchContext ctx); diff --git a/Scribe/Shapes/Shape_T.ToAnalyzer.cs b/Scribe/Shapes/Shape_T.ToAnalyzer.cs index 84ece93..e1397b6 100644 --- a/Scribe/Shapes/Shape_T.ToAnalyzer.cs +++ b/Scribe/Shapes/Shape_T.ToAnalyzer.cs @@ -35,8 +35,56 @@ internal ImmutableArray SupportedDescriptors { if (_cachedDescriptors.IsDefault) { - var builder = ImmutableArray.CreateBuilder( - _checks.Length + _memberChecks.Length); + var builder = ImmutableArray.CreateBuilder(); + + if (_alternatives is not null) + { + // Fusion diagnostic + every branch's descriptors (so SuppressMessage / + // ruleset authoring still resolves each individual Id even though the + // fused message is what gets emitted). + var fusion = _fusionSpec!; + builder.Add(_descriptorsById.GetOrAdd(fusion.Id, _ => new DiagnosticDescriptor( + id: fusion.Id, + title: fusion.Title, + messageFormat: fusion.MessageFormat, + category: "Scribe.Shape", + defaultSeverity: fusion.Severity, + isEnabledByDefault: true))); + + builder.Add(_descriptorsById.GetOrAdd("SCRIBE101", _ => new DiagnosticDescriptor( + id: "SCRIBE101", + title: "OneOf branch kind mismatch", + messageFormat: "Branch expected type-kind filter '{0}'; symbol does not match.", + category: "Scribe.Shape", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true))); + + foreach (var alt in _alternatives) + { + foreach (var check in alt.Checks) + { + builder.Add(DescriptorFor(check)); + } + + foreach (var branch in alt.LensBranches) + { + foreach (var (id, title, messageFormat, severity) in branch.CollectDescriptors()) + { + builder.Add(_descriptorsById.GetOrAdd(id, _ => new DiagnosticDescriptor( + id: id, + title: title, + messageFormat: messageFormat, + category: "Scribe.Shape", + defaultSeverity: severity, + isEnabledByDefault: true))); + } + } + } + + _cachedDescriptors = builder.ToImmutable(); + return _cachedDescriptors; + } + foreach (var check in _checks) { builder.Add(DescriptorFor(check)); @@ -47,6 +95,20 @@ internal ImmutableArray SupportedDescriptors builder.Add(DescriptorFor(check)); } + foreach (var branch in _lensBranches) + { + foreach (var (id, title, messageFormat, severity) in branch.CollectDescriptors()) + { + builder.Add(_descriptorsById.GetOrAdd(id, _ => new DiagnosticDescriptor( + id: id, + title: title, + messageFormat: messageFormat, + category: "Scribe.Shape", + defaultSeverity: severity, + isEnabledByDefault: true))); + } + } + _cachedDescriptors = builder.ToImmutable(); } @@ -81,6 +143,10 @@ internal DiagnosticDescriptor DescriptorFor(MemberCheck check) internal string? PrimaryInterfaceName => _primaryInterfaceMetadataName; internal ShapeCheck[] CheckList => _checks; internal MemberCheck[] MemberCheckList => _memberChecks; + internal ILensBranch[] LensBranchList => _lensBranches; + internal ConcurrentDictionary DescriptorsById => _descriptorsById; + + internal static Location? FirstLocationOf(INamedTypeSymbol symbol) => FirstLocation(symbol); // RS1001 flags DiagnosticAnalyzer subclasses missing [DiagnosticAnalyzer]. This nested // class is intentionally unattributed: as a nested type of an open generic it would @@ -112,6 +178,12 @@ private void AnalyzeNamedType(SymbolAnalysisContext ctx) return; } + if (_shape.IsOneOf) + { + AnalyzeOneOf(ctx, type); + return; + } + if (!MatchesKind(_shape.KindFilter, type)) { return; @@ -131,18 +203,27 @@ private void AnalyzeNamedType(SymbolAnalysisContext ctx) return; } + TypeFocus? typeFocus = null; + if (_shape.CheckList.Length > 0 || _shape.LensBranchList.Length > 0) + { + typeFocus = new TypeFocus( + symbol: type, + fqn: Scribe.Cache.InternPool.Intern(type.ToDisplayString()), + origin: Scribe.Cache.LocationInfo.From(Shape.FirstLocationOf(type))); + } + foreach (var check in _shape.CheckList) { ctx.CancellationToken.ThrowIfCancellationRequested(); - if (check.Predicate(type, ctx.Compilation, ctx.CancellationToken)) + if (check.Predicate(typeFocus!.Value, ctx.Compilation, ctx.CancellationToken)) { continue; } var descriptor = _shape.DescriptorFor(check); var location = SquiggleLocator.Resolve(type, check.SquiggleAt, ctx.CancellationToken); - var properties = BuildProperties(check, type); - var args = check.MessageArgs(type); + var properties = BuildProperties(check, typeFocus!.Value); + var args = check.MessageArgs(typeFocus!.Value); var objArgs = new object?[args.Count]; for (var i = 0; i < args.Count; i++) { @@ -156,6 +237,23 @@ private void AnalyzeNamedType(SymbolAnalysisContext ctx) objArgs)); } + if (_shape.LensBranchList.Length > 0) + { + var branchViolations = new List(); + foreach (var branch in _shape.LensBranchList) + { + branch.Evaluate(typeFocus!.Value, ctx.Compilation, ctx.CancellationToken, branchViolations); + } + + foreach (var violation in branchViolations) + { + if (_shape.DescriptorsById.TryGetValue(violation.Id, out var descriptor)) + { + ctx.ReportDiagnostic(violation.Materialize(descriptor)); + } + } + } + if (_shape.MemberCheckList.Length > 0) { foreach (var member in EnumerateDeclaredMembers(type)) @@ -189,10 +287,132 @@ private void AnalyzeNamedType(SymbolAnalysisContext ctx) } } + private void AnalyzeOneOf(SymbolAnalysisContext ctx, INamedTypeSymbol type) + { + // Overall kind filter: at least one alternative's kind must match. + var anyKindMatch = false; + foreach (var alt in _shape.Alternatives!) + { + if (Shape.MatchesSymbolKindSingle(alt.Kind, type)) + { + anyKindMatch = true; + break; + } + } + + if (!anyKindMatch) + { + return; + } + + if (_shape.PrimaryAttributeName is { } attrName + && !HasAttribute(type, attrName)) + { + return; + } + + if (_shape.PrimaryInterfaceName is { } ifaceName + && !Shape.ImplementsInterface(type, ctx.Compilation, ifaceName)) + { + return; + } + + var typeFocus = new TypeFocus( + symbol: type, + fqn: Scribe.Cache.InternPool.Intern(type.ToDisplayString()), + origin: Scribe.Cache.LocationInfo.From(Shape.FirstLocationOf(type))); + + var alternatives = _shape.Alternatives!; + var perBranchCounts = new int[alternatives.Length]; + var perBranchIds = new List[alternatives.Length]; + var anyBranchPassed = false; + + for (var i = 0; i < alternatives.Length; i++) + { + var alt = alternatives[i]; + perBranchIds[i] = new List(); + + if (!Shape.MatchesSymbolKindSingle(alt.Kind, type)) + { + perBranchIds[i].Add("SCRIBE101"); + perBranchCounts[i] = 1; + continue; + } + + foreach (var check in alt.Checks) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + if (check.Predicate(typeFocus, ctx.Compilation, ctx.CancellationToken)) + { + continue; + } + + perBranchIds[i].Add(check.Id); + perBranchCounts[i]++; + } + + if (alt.LensBranches.Length > 0) + { + var scratch = new List(); + foreach (var branch in alt.LensBranches) + { + branch.Evaluate(typeFocus, ctx.Compilation, ctx.CancellationToken, scratch); + } + + foreach (var violation in scratch) + { + perBranchIds[i].Add(violation.Id); + perBranchCounts[i]++; + } + } + + if (perBranchCounts[i] == 0) + { + anyBranchPassed = true; + break; + } + } + + if (anyBranchPassed) + { + return; + } + + var summary = new System.Text.StringBuilder(); + for (var i = 0; i < alternatives.Length; i++) + { + if (i > 0) + { + summary.Append(" —OR— "); + } + + summary.Append("[branch").Append(i + 1).Append(": "); + for (var j = 0; j < perBranchIds[i].Count; j++) + { + if (j > 0) + { + summary.Append(", "); + } + + summary.Append(perBranchIds[i][j]); + } + + summary.Append(']'); + } + + var fusionDescriptor = _shape.DescriptorsById[_shape.FusionSpec!.Id]; + var location = Shape.FirstLocationOf(type); + ctx.ReportDiagnostic(Diagnostic.Create( + fusionDescriptor, + location, + alternatives.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), + summary.ToString())); + } + private static ImmutableDictionary BuildProperties( - ShapeCheck check, INamedTypeSymbol symbol) + ShapeCheck check, TypeFocus focus) { - var props = check.FixProperties?.Invoke(symbol) + var props = check.FixProperties?.Invoke(focus) ?? ImmutableDictionary.Empty; return props diff --git a/Scribe/Shapes/Shape_T.cs b/Scribe/Shapes/Shape_T.cs index 79be24c..36802c9 100644 --- a/Scribe/Shapes/Shape_T.cs +++ b/Scribe/Shapes/Shape_T.cs @@ -8,20 +8,23 @@ namespace Scribe.Shapes; /// -/// A sealed shape typed with its projection . Produced -/// by . In Phase 3 only -/// is exposed; ToAnalyzer and ToFixProvider -/// ship in Phase 4. +/// A sealed, etched Shape typed with its model . Produced +/// by . Materialises into an analyzer, a generator +/// provider, or a code-fixer via / ToAnalyzer / +/// ToInk. /// -/// User projection type. Must be for cache correctness. +/// User model type. Must be for cache correctness. public sealed partial class Shape where TModel : IEquatable { private readonly TypeKindFilter _kind; private readonly ShapeCheck[] _checks; private readonly MemberCheck[] _memberChecks; + private readonly ILensBranch[] _lensBranches; private readonly string? _primaryAttributeMetadataName; private readonly string? _primaryInterfaceMetadataName; - private readonly ProjectionDelegate _project; + private readonly EtchDelegate _etch; + private readonly OneOfBranch[]? _alternatives; + private readonly LensQuantifierSpec? _fusionSpec; private readonly System.Collections.Concurrent.ConcurrentDictionary _customFixes = new(StringComparer.Ordinal); @@ -29,18 +32,44 @@ internal Shape( TypeKindFilter kind, ShapeCheck[] checks, MemberCheck[] memberChecks, + ILensBranch[] lensBranches, string? primaryAttributeMetadataName, string? primaryInterfaceMetadataName, - ProjectionDelegate project) + EtchDelegate etch) { _kind = kind; _checks = checks; _memberChecks = memberChecks; + _lensBranches = lensBranches; _primaryAttributeMetadataName = primaryAttributeMetadataName; _primaryInterfaceMetadataName = primaryInterfaceMetadataName; - _project = project; + _etch = etch; + _alternatives = null; + _fusionSpec = null; } + internal Shape( + OneOfBranch[] alternatives, + string? primaryAttributeMetadataName, + string? primaryInterfaceMetadataName, + LensQuantifierSpec fusionSpec, + EtchDelegate etch) + { + _kind = TypeKindFilter.Any; + _checks = Array.Empty(); + _memberChecks = Array.Empty(); + _lensBranches = Array.Empty>(); + _primaryAttributeMetadataName = primaryAttributeMetadataName; + _primaryInterfaceMetadataName = primaryInterfaceMetadataName; + _etch = etch; + _alternatives = alternatives ?? throw new ArgumentNullException(nameof(alternatives)); + _fusionSpec = fusionSpec ?? throw new ArgumentNullException(nameof(fusionSpec)); + } + + internal bool IsOneOf => _alternatives is not null; + internal OneOfBranch[]? Alternatives => _alternatives; + internal LensQuantifierSpec? FusionSpec => _fusionSpec; + /// /// Register a custom fix handler under . The handler's /// runtime type is opaque to Scribe core — the Ink extension layer @@ -120,16 +149,49 @@ public IncrementalValuesProvider> ToProvider( private bool MatchesNodeKind(SyntaxNode node) { - // Delegate to the builder's kind matcher by reconstructing a lightweight check. - return _kind switch + if (_alternatives is not null) + { + foreach (var alt in _alternatives) + { + if (MatchesNodeKindSingle(alt.Kind, node)) + { + return true; + } + } + + return false; + } + + return MatchesNodeKindSingle(_kind, node); + } + + private static bool MatchesNodeKindSingle(TypeKindFilter kind, SyntaxNode node) => + kind switch { TypeKindFilter.Any => node is Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax, - _ => KindMatcher.Matches(_kind, node), + _ => KindMatcher.Matches(kind, node), }; + + private bool MatchesSymbolKind(INamedTypeSymbol symbol) + { + if (_alternatives is not null) + { + foreach (var alt in _alternatives) + { + if (MatchesSymbolKindSingle(alt.Kind, symbol)) + { + return true; + } + } + + return false; + } + + return MatchesSymbolKindSingle(_kind, symbol); } - private bool MatchesSymbolKind(INamedTypeSymbol symbol) => - _kind switch + internal static bool MatchesSymbolKindSingle(TypeKindFilter kind, INamedTypeSymbol symbol) => + kind switch { TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord, TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord, @@ -162,9 +224,9 @@ private bool MatchesSymbolKind(INamedTypeSymbol symbol) => ? AttributeSchema.For(symbol, _primaryAttributeMetadataName) : default; - var projectionContext = new ShapeProjectionContext( + var etchContext = new ShapeEtchContext( symbol, attributeReader, semanticModel, compilation, ct); - var model = _project(in projectionContext); + var model = _etch(in etchContext); var location = LocationInfo.From(FirstLocation(symbol)); @@ -178,16 +240,30 @@ private bool MatchesSymbolKind(INamedTypeSymbol symbol) => private EquatableArray RunChecks( INamedTypeSymbol symbol, Compilation compilation, CancellationToken ct) { - if (_checks.Length == 0 && _memberChecks.Length == 0) + if (_alternatives is not null) + { + return RunOneOfChecks(symbol, compilation, ct); + } + + if (_checks.Length == 0 && _memberChecks.Length == 0 && _lensBranches.Length == 0) { return EquatableArray.Empty; } List? violations = null; + TypeFocus? typeFocus = null; + if (_checks.Length > 0 || _lensBranches.Length > 0) + { + typeFocus = new TypeFocus( + symbol: symbol, + fqn: InternPool.Intern(symbol.ToDisplayString()), + origin: LocationInfo.From(FirstLocation(symbol))); + } + foreach (var check in _checks) { ct.ThrowIfCancellationRequested(); - if (check.Predicate(symbol, compilation, ct)) + if (check.Predicate(typeFocus!.Value, compilation, ct)) { continue; } @@ -196,7 +272,7 @@ private EquatableArray RunChecks( violations.Add(new DiagnosticInfo( Id: check.Id, Severity: check.Severity, - MessageArgs: check.MessageArgs(symbol), + MessageArgs: check.MessageArgs(typeFocus!.Value), Location: LocationInfo.From(FirstLocation(symbol)))); } @@ -222,11 +298,123 @@ private EquatableArray RunChecks( } } + if (_lensBranches.Length > 0) + { + foreach (var branch in _lensBranches) + { + violations ??= new List(); + branch.Evaluate(typeFocus!.Value, compilation, ct, violations); + } + } + return violations is null ? EquatableArray.Empty : EquatableArray.From(violations); } + private EquatableArray RunOneOfChecks( + INamedTypeSymbol symbol, Compilation compilation, CancellationToken ct) + { + var alternatives = _alternatives!; + var typeFocus = new TypeFocus( + symbol: symbol, + fqn: InternPool.Intern(symbol.ToDisplayString()), + origin: LocationInfo.From(FirstLocation(symbol))); + + var perBranchViolations = new List[alternatives.Length]; + + for (var i = 0; i < alternatives.Length; i++) + { + ct.ThrowIfCancellationRequested(); + var alt = alternatives[i]; + var scratch = new List(); + + if (!MatchesSymbolKindSingle(alt.Kind, symbol)) + { + // Kind mismatch is itself a branch failure; synthesise a marker so fusion + // can surface the reason "this branch didn't match your symbol kind". + scratch.Add(new DiagnosticInfo( + Id: "SCRIBE101", + Severity: DiagnosticSeverity.Info, + MessageArgs: EquatableArray.Create(alt.Kind.ToString()), + Location: typeFocus.Origin)); + perBranchViolations[i] = scratch; + continue; + } + + foreach (var check in alt.Checks) + { + ct.ThrowIfCancellationRequested(); + if (check.Predicate(typeFocus, compilation, ct)) + { + continue; + } + + scratch.Add(new DiagnosticInfo( + Id: check.Id, + Severity: check.Severity, + MessageArgs: check.MessageArgs(typeFocus), + Location: typeFocus.Origin)); + } + + foreach (var branch in alt.LensBranches) + { + branch.Evaluate(typeFocus, compilation, ct, scratch); + } + + perBranchViolations[i] = scratch; + } + + // Any branch with zero violations is a pass — overall silent. + for (var i = 0; i < perBranchViolations.Length; i++) + { + if (perBranchViolations[i].Count == 0) + { + return EquatableArray.Empty; + } + } + + var summary = FormatFusionSummary(perBranchViolations); + var fusion = new DiagnosticInfo( + Id: _fusionSpec!.Id, + Severity: _fusionSpec.Severity, + MessageArgs: EquatableArray.Create( + alternatives.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), + summary), + Location: typeFocus.Origin); + + return EquatableArray.From(new[] { fusion }); + } + + private static string FormatFusionSummary(List[] perBranchViolations) + { + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < perBranchViolations.Length; i++) + { + if (i > 0) + { + builder.Append(" —OR— "); + } + + builder.Append('['); + builder.Append("branch").Append(i + 1).Append(": "); + var branch = perBranchViolations[i]; + for (var j = 0; j < branch.Count; j++) + { + if (j > 0) + { + builder.Append(", "); + } + + builder.Append(branch[j].Id); + } + + builder.Append(']'); + } + + return builder.ToString(); + } + internal static IEnumerable EnumerateDeclaredMembers(INamedTypeSymbol type) { // Stable source-order iteration: sort by first declaring syntax span. diff --git a/Scribe/Shapes/ShapedPair.cs b/Scribe/Shapes/ShapedPrism.cs similarity index 79% rename from Scribe/Shapes/ShapedPair.cs rename to Scribe/Shapes/ShapedPrism.cs index c8df8a4..04c5e32 100644 --- a/Scribe/Shapes/ShapedPair.cs +++ b/Scribe/Shapes/ShapedPrism.cs @@ -4,12 +4,12 @@ namespace Scribe.Shapes; /// /// A value-equatable pair of s produced by -/// . Flows through +/// . Flows through /// /// so that joined-pair consumers react only to actual pairing changes, not /// unrelated edits on either side. /// -public readonly record struct ShapedPair( +public readonly record struct ShapedPrism( ShapedSymbol Left, ShapedSymbol Right) where TLeft : IEquatable diff --git a/Scribe/Shapes/SquiggleAt.cs b/Scribe/Shapes/SquiggleAt.cs index 390ff5a..1b7b1a9 100644 --- a/Scribe/Shapes/SquiggleAt.cs +++ b/Scribe/Shapes/SquiggleAt.cs @@ -2,7 +2,7 @@ namespace Scribe.Shapes; /// /// Closed enumeration of squiggle anchor points on a type declaration. -/// Each primitive picks an opinionated default +/// Each primitive picks an opinionated default /// so the diagnostic lands in the most editorially-useful place. /// public enum SquiggleAt diff --git a/Scribe/Shapes/Stencil.cs b/Scribe/Shapes/Stencil.cs new file mode 100644 index 0000000..a5af33a --- /dev/null +++ b/Scribe/Shapes/Stencil.cs @@ -0,0 +1,65 @@ +namespace Scribe.Shapes; + +/// +/// The door to the Shape DSL. Pick the type-kind you want to match and +/// Expose a focused ; then fluently layer +/// MustBeX constraints and seal the chain with +/// . +/// +/// +/// +/// The lithography metaphor runs end-to-end: the Stencil is the mask, +/// (and peers) is the UV exposure step that prints +/// the latent pattern onto the wafer, and +/// is the permanent commitment of the finished pattern into the substrate. +/// +/// +/// +/// +/// Shape<ThingModel> shape = Stencil.ExposeClass() +/// .MustHaveAttribute<ThingAttribute>() +/// .MustBePartial() +/// .MustImplement<IThing>() +/// .Etch(in ctx => new ThingModel( +/// Fqn: ctx.Fqn, +/// Flavor: ctx.Attribute.Ctor<string>(0) ?? "default")); +/// +/// +public static class Stencil +{ + /// Expose a over class declarations. + public static TypeShape ExposeClass() => new(TypeKindFilter.Class); + + /// Expose a over record declarations (class form). + public static TypeShape ExposeRecord() => new(TypeKindFilter.Record); + + /// Expose a over record struct declarations. + public static TypeShape ExposeRecordStruct() => new(TypeKindFilter.RecordStruct); + + /// Expose a over struct declarations. + public static TypeShape ExposeStruct() => new(TypeKindFilter.Struct); + + /// Expose a over interface declarations. + public static TypeShape ExposeInterface() => new(TypeKindFilter.Interface); + + /// Expose a over any type declaration regardless of kind. + public static TypeShape ExposeAnyType() => new(TypeKindFilter.Any); + + /// + /// Compose two or more alternatives into a single + /// . The resulting shape passes a symbol when + /// at least one alternative's check tree is silent against it; when every + /// alternative fails, a single fused diagnostic summarises each branch's + /// unsatisfied check IDs. + /// + /// Two or more fully-authored branches. Each branch supplies its own kind filter and checks. + public static OneOfTypeShape OneOf(params TypeShape[] alternatives) => + new(alternatives, fusionSpec: null); + + /// + /// Overload of that accepts a + /// override for the fused diagnostic. + /// + public static OneOfTypeShape OneOf(DiagnosticSpec fusionSpec, params TypeShape[] alternatives) => + new(alternatives, fusionSpec); +} diff --git a/Scribe/Shapes/TypeArgFocus.cs b/Scribe/Shapes/TypeArgFocus.cs new file mode 100644 index 0000000..e978815 --- /dev/null +++ b/Scribe/Shapes/TypeArgFocus.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for a single type argument at a specific position inside a +/// generic attribute, base-list entry, or method signature. Produced by the +/// GenericTypeArg(index) lens. +/// +/// +/// +/// The identity breadcrumb is (TypeFqn, Index, ParentOrigin, Origin). +/// disambiguates the same (Fqn, Index) pair when the +/// navigation comes from two different parent foci — e.g. the same +/// string argument at index 0 on two different [Foo<string>] +/// attribute usages. +/// +/// +/// is carried for in-flight use and excluded from equality. +/// +/// +public readonly struct TypeArgFocus : IEquatable +{ + /// The type argument symbol. Excluded from equality — do not use for caching. + public ITypeSymbol Symbol { get; } + + /// Fully-qualified name of the type argument (interned). + public string TypeFqn { get; } + + /// Zero-based position of the argument in its generic argument list. + public int Index { get; } + + /// + /// Source span of the parent focus — used to disambiguate when the same + /// (TypeFqn, Index) appears under multiple parents in the same compilation. + /// + public LocationInfo? ParentOrigin { get; } + + /// Source span of the TypeSyntax at the generic position — the cache-stable breadcrumb. + public LocationInfo? Origin { get; } + + public TypeArgFocus( + ITypeSymbol symbol, + string typeFqn, + int index, + LocationInfo? parentOrigin, + LocationInfo? origin) + { + Symbol = symbol; + TypeFqn = typeFqn; + Index = index; + ParentOrigin = parentOrigin; + Origin = origin; + } + + public bool Equals(TypeArgFocus other) => + string.Equals(TypeFqn, other.TypeFqn, StringComparison.Ordinal) + && Index == other.Index + && Nullable.Equals(ParentOrigin, other.ParentOrigin) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is TypeArgFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = TypeFqn is null ? 0 : StringComparer.Ordinal.GetHashCode(TypeFqn); + hash = (hash * 397) ^ Index; + hash = (hash * 397) ^ (ParentOrigin?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(TypeArgFocus left, TypeArgFocus right) => left.Equals(right); + + public static bool operator !=(TypeArgFocus left, TypeArgFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/TypeArgFocusLeafPredicates.cs b/Scribe/Shapes/TypeArgFocusLeafPredicates.cs new file mode 100644 index 0000000..83d34fa --- /dev/null +++ b/Scribe/Shapes/TypeArgFocusLeafPredicates.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Leaf-predicate extensions on . v1 subset +/// per the gap analysis B.4 catalogue — covers the "does this generic type argument +/// satisfy a structural relationship?" checks. For broader type-shape validation on +/// the argument, use . +/// +public static class TypeArgFocusLeafPredicates +{ + /// + /// Require the type argument to implement the interface named by + /// . Metadata name matches either the fully + /// qualified display string (e.g. System.IDisposable) or the arity-stripped + /// generic form (e.g. System.Collections.Generic.IEnumerable for + /// IEnumerable<T>). + /// + public static FocusShape MustImplement( + this FocusShape shape, + string metadataName, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE070"), + Title: spec?.Title ?? "Type argument must implement required interface", + MessageFormat: spec?.Message ?? "Type argument '{0}' must implement '{1}'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => Implements(focus.Symbol, interned), + MessageArgs: focus => EquatableArray.Create(focus.TypeFqn, interned))); + return shape; + } + + /// + /// Require the type argument to derive (directly or transitively) from the base + /// class named by . + /// + public static FocusShape MustDeriveFrom( + this FocusShape shape, + string metadataName, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE071"), + Title: spec?.Title ?? "Type argument must derive from required base", + MessageFormat: spec?.Message ?? "Type argument '{0}' must derive from '{1}'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => DerivesFrom(focus.Symbol, interned), + MessageArgs: focus => EquatableArray.Create(focus.TypeFqn, interned))); + return shape; + } + + private static bool Implements(ITypeSymbol symbol, string metadataName) + { + foreach (var iface in symbol.AllInterfaces) + { + if (NameMatches(iface, metadataName)) + { + return true; + } + } + + return false; + } + + private static bool DerivesFrom(ITypeSymbol symbol, string metadataName) + { + for (var current = symbol.BaseType; current is not null; current = current.BaseType) + { + if (NameMatches(current, metadataName)) + { + return true; + } + } + + return false; + } + + private static bool NameMatches(INamedTypeSymbol symbol, string metadataName) + { + var fqn = symbol.ToDisplayString(); + if (string.Equals(fqn, metadataName, StringComparison.Ordinal)) + { + return true; + } + + var bracket = fqn.IndexOf('<'); + var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn; + return string.Equals(bare, metadataName, StringComparison.Ordinal); + } +} diff --git a/Scribe/Shapes/TypeArgFocusShapeExtensions.cs b/Scribe/Shapes/TypeArgFocusShapeExtensions.cs new file mode 100644 index 0000000..8b4ecf9 --- /dev/null +++ b/Scribe/Shapes/TypeArgFocusShapeExtensions.cs @@ -0,0 +1,44 @@ +using System; + +namespace Scribe.Shapes; + +/// +/// Fluent sub-lens entry points on . Currently +/// exposes — re-entering the type-shape world rooted at +/// the type argument to reuse type-level lenses (attributes, members, +/// base-type-chain) underneath a generic position. +/// +public static class TypeArgFocusShapeExtensions +{ + /// + /// Enter a nested rooted at the type + /// argument. Yields no focus (silent pass) when the argument's symbol is not an + /// — type parameters, + /// arrays, pointers, and function-pointer types all pass silently. Downstream + /// lens branches declared on the nested shape evaluate against each named type + /// argument. + /// + public static FocusShape AsTypeShape( + this FocusShape shape, + Action>? configure = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.TypeArgAsTypeShape(); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: 0, + MaxCount: null, + Presence: null, + ParentOrigin: parent => parent.Origin)); + + return shape; + } +} diff --git a/Scribe/Shapes/TypeFocus.cs b/Scribe/Shapes/TypeFocus.cs new file mode 100644 index 0000000..2a262fb --- /dev/null +++ b/Scribe/Shapes/TypeFocus.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Equatable identity for a type being matched by a . Pairs +/// the matched with a cache-stable breadcrumb +/// (fully-qualified name + declaration location) so the incremental pipeline can +/// discriminate rows without relying on the non-stable symbol reference. +/// +/// +/// +/// is carried so in-flight predicates and projections can +/// read from it, but it is not part of equality. Roslyn recomputes +/// instances across compilations — using the +/// symbol reference as a cache key would invalidate every downstream step on +/// every change. Equality uses and , +/// which are stable. +/// +/// +public readonly struct TypeFocus : IEquatable +{ + /// The matched type symbol. Excluded from equality — do not rely on it for caching. + public INamedTypeSymbol Symbol { get; } + + /// Fully-qualified display name of the matched type (interned). + public string Fqn { get; } + + /// Declaration location — the cache-stable breadcrumb used for equality. + public LocationInfo? Origin { get; } + + public TypeFocus(INamedTypeSymbol symbol, string fqn, LocationInfo? origin) + { + Symbol = symbol; + Fqn = fqn; + Origin = origin; + } + + public bool Equals(TypeFocus other) => + string.Equals(Fqn, other.Fqn, StringComparison.Ordinal) + && Nullable.Equals(Origin, other.Origin); + + public override bool Equals(object? obj) => obj is TypeFocus other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = Fqn is null ? 0 : StringComparer.Ordinal.GetHashCode(Fqn); + hash = (hash * 397) ^ (Origin?.GetHashCode() ?? 0); + return hash; + } + } + + public static bool operator ==(TypeFocus left, TypeFocus right) => left.Equals(right); + + public static bool operator !=(TypeFocus left, TypeFocus right) => !left.Equals(right); +} diff --git a/Scribe/Shapes/TypeFocusLeafPredicates.cs b/Scribe/Shapes/TypeFocusLeafPredicates.cs new file mode 100644 index 0000000..425fea1 --- /dev/null +++ b/Scribe/Shapes/TypeFocusLeafPredicates.cs @@ -0,0 +1,160 @@ +using System; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Leaf-predicate extensions on . Parallel to +/// 's core MustBe* / MustHave* catalog but +/// targeted at a nested type-shape sub-graph — e.g. a +/// obtained via +/// or +/// . Diagnostic IDs match +/// the root catalog so the same descriptors are reused when both surfaces coexist. +/// +/// +/// This is a representative subset (v1). The root catalog is +/// larger — additional primitives will be added here as needed. Auto-fix metadata +/// (squiggle targets and ) is not propagated: violations emitted +/// on a nested focus land on the lens's smudge anchor, and the code-fix layer is +/// scoped to root-level checks in v1. +/// +public static class TypeFocusLeafPredicates +{ + /// Require the type to be declared partial. + public static FocusShape MustBePartial( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE001"), + Title: spec?.Title ?? "Type must be partial", + MessageFormat: spec?.Message ?? "Type '{0}' must be declared 'partial'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, ct) => TypeShape.IsPartial(focus.Symbol, ct), + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// Require the type to be declared sealed (or be a value / static type). + public static FocusShape MustBeSealed( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE005"), + Title: spec?.Title ?? "Type must be sealed", + MessageFormat: spec?.Message ?? "Type '{0}' must be declared 'sealed'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => + focus.Symbol.IsSealed || focus.Symbol.IsValueType || focus.Symbol.IsStatic, + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// Require the type to be declared abstract. + public static FocusShape MustBeAbstract( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE015"), + Title: spec?.Title ?? "Type must be abstract", + MessageFormat: spec?.Message ?? "Type '{0}' must be declared 'abstract'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => focus.Symbol.IsAbstract, + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// Require the type to be declared static. + public static FocusShape MustBeStatic( + this FocusShape shape, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE017"), + Title: spec?.Title ?? "Type must be static", + MessageFormat: spec?.Message ?? "Type '{0}' must be declared 'static'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: static (focus, _, _) => focus.Symbol.IsStatic, + MessageArgs: static focus => EquatableArray.Create(focus.Symbol.Name))); + return shape; + } + + /// Require the type to carry the attribute named by . + public static FocusShape MustHaveAttribute( + this FocusShape shape, + string metadataName, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE003"), + Title: spec?.Title ?? "Type must have required attribute", + MessageFormat: spec?.Message ?? "Type '{0}' must be annotated with '[{1}]'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => TypeShape.HasAttribute(focus.Symbol, interned), + MessageArgs: focus => EquatableArray.Create(focus.Symbol.Name, interned))); + return shape; + } + + /// Require the type's name to match the regular expression . + public static FocusShape MustBeNamed( + this FocusShape shape, + string pattern, + DiagnosticSpec? spec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(pattern)) + { + throw new ArgumentException("Pattern must not be empty.", nameof(pattern)); + } + + var regex = new Regex(pattern, RegexOptions.CultureInvariant); + shape.AddCheck(new FocusCheck( + Id: InternPool.Intern(spec?.Id ?? "SCRIBE029"), + Title: spec?.Title ?? "Type name must match required pattern", + MessageFormat: spec?.Message ?? "Type '{0}' name does not match pattern '{1}'", + Severity: spec?.Severity ?? DiagnosticSeverity.Error, + Predicate: (focus, _, _) => regex.IsMatch(focus.Symbol.Name), + MessageArgs: focus => EquatableArray.Create(focus.Symbol.Name, pattern))); + return shape; + } +} diff --git a/Scribe/Shapes/TypeFocusShapeExtensions.cs b/Scribe/Shapes/TypeFocusShapeExtensions.cs new file mode 100644 index 0000000..dc9b4fc --- /dev/null +++ b/Scribe/Shapes/TypeFocusShapeExtensions.cs @@ -0,0 +1,210 @@ +using System; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Fluent lens entry points on . These extensions +/// mirror the type-level navigation surface exposed on — +/// Attributes, Members, BaseTypeChain — so nested +/// sub-shapes (e.g. obtained via +/// AsTypeShape()) can compose identically to a root type shape. +/// +public static class TypeFocusShapeExtensions +{ + /// + /// Enter the attributes lens: navigate every attribute application matching + /// . Mirrors + /// . + /// + public static FocusShape Attributes( + this FocusShape shape, + string attributeFqn, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + if (string.IsNullOrEmpty(attributeFqn)) + { + throw new ArgumentException("Attribute FQN must not be empty.", nameof(attributeFqn)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.Attributes(attributeFqn); + + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE050", + defaultTitle: "Attribute presence constraint", + defaultMessage: "Expected [{0}..{1}] applications of attribute '" + + attributeFqn + "', observed {2}"); + + var quantifierDescriptor = TypeShape.BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE092" : "SCRIBE093", + defaultMessage: quantifier == Quantifier.Any + ? "At least one application of attribute '" + attributeFqn + "' must satisfy the required checks" + : "No application of attribute '" + attributeFqn + "' may satisfy the disallowed checks"); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor)); + + return shape; + } + + /// + /// Enter the members lens. Mirrors . + /// + public static FocusShape Members( + this FocusShape shape, + SymbolKind? kind = null, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.Members(kind); + + var kindDescription = kind?.ToString() ?? "any kind"; + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE051", + defaultTitle: "Member presence constraint", + defaultMessage: "Expected [{0}..{1}] members of " + kindDescription + ", observed {2}"); + + var quantifierDescriptor = TypeShape.BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE090" : "SCRIBE091", + defaultMessage: quantifier == Quantifier.Any + ? "At least one member of " + kindDescription + " must satisfy the required checks" + : "No member of " + kindDescription + " may satisfy the disallowed checks"); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor)); + + return shape; + } + + /// + /// Enter the base-type-chain lens. Mirrors . + /// + public static FocusShape BaseTypeChain( + this FocusShape shape, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + ValidateCounts(min, max); + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.BaseTypeChain(); + + var presence = BuildPresence( + min, max, presenceSpec, + defaultId: "SCRIBE052", + defaultTitle: "Base-type-chain length constraint", + defaultMessage: "Expected [{0}..{1}] base-type-chain steps, observed {2}"); + + var quantifierDescriptor = TypeShape.BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE094" : "SCRIBE095", + defaultMessage: quantifier == Quantifier.Any + ? "At least one base-type-chain step must satisfy the required checks" + : "No base-type-chain step may satisfy the disallowed checks"); + + shape.AddBranch(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor)); + + return shape; + } + + private static void ValidateCounts(int min, int? max) + { + if (min < 0) + { + throw new ArgumentOutOfRangeException(nameof(min), "Minimum count must be non-negative."); + } + + if (max is { } m && m < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Maximum count must be at least the minimum."); + } + } + + private static LensPresenceSpec? BuildPresence( + int min, + int? max, + DiagnosticSpec? spec, + string defaultId, + string defaultTitle, + string defaultMessage) + { + var hasPresence = min > 0 || max is not null || spec is not null; + if (!hasPresence) + { + return null; + } + + return new LensPresenceSpec( + Id: InternPool.Intern(spec?.Id ?? defaultId), + Title: spec?.Title ?? defaultTitle, + MessageFormat: spec?.Message ?? defaultMessage, + Severity: spec?.Severity ?? DiagnosticSeverity.Error); + } +} diff --git a/Scribe/Shapes/TypeKindFilter.cs b/Scribe/Shapes/TypeKindFilter.cs index da106fe..e01a139 100644 --- a/Scribe/Shapes/TypeKindFilter.cs +++ b/Scribe/Shapes/TypeKindFilter.cs @@ -2,7 +2,7 @@ namespace Scribe.Shapes; /// /// Coarse type-kind filter applied at the syntax stage before semantic analysis. -/// Picked by , , etc. +/// Picked by , , etc. /// internal enum TypeKindFilter { diff --git a/Scribe/Shapes/TypeShape.Linq.cs b/Scribe/Shapes/TypeShape.Linq.cs new file mode 100644 index 0000000..e4a59ea --- /dev/null +++ b/Scribe/Shapes/TypeShape.Linq.cs @@ -0,0 +1,115 @@ +using System; +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// B.9 — LINQ query-comprehension parity. These pass-throughs give the fluent +/// Shape DSL the verb names the C# query-comprehension desugarer expects, so +/// from / where / select against a compiles +/// without wrappers. +/// +/// +/// +/// select () is an alias for +/// . where (the single-argument +/// Where) is an alias for accepting a +/// focus-shaped predicate. +/// +/// +/// Multi-focus composition (from t in shape from a in t.Attributes(...)) +/// is expressed in v1 via the lens-entry callbacks (Attributes, +/// Members, BaseTypeChain). A true SelectMany that joins +/// two focus streams into a multi-variable comprehension is deferred to a +/// later phase. +/// +/// +public sealed partial class TypeShape +{ + /// + /// LINQ alias for . Receives a + /// (the non-ref, cache-safe view of the matched type) rather than a + /// , so an implicit-typed lambda — as used by the + /// query-comprehension desugarer — binds cleanly. + /// + /// Equatable model type produced for each surviving row. + /// Projection from the focus to the terminal model. + public Shape Select(Func selector) + where TModel : IEquatable + { + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Etch((in ShapeEtchContext ctx) => + { + var focus = new TypeFocus( + symbol: ctx.Symbol, + fqn: InternPool.Intern(ctx.Fqn), + origin: LocationInfo.From(FirstLocation(ctx.Symbol))); + return selector(focus); + }); + } + + /// + /// LINQ alias for a focus-shaped . Registers a positive + /// predicate — means the Shape is satisfied — and + /// reports SCRIBE200 when it fails. This single-argument form is what + /// the query-comprehension where clause desugars to; use the + /// overload when + /// you want a stable custom diagnostic id or message. + /// + /// Positive predicate evaluated against the matched type's focus. + public TypeShape Where(Func predicate) => + Where(predicate, new DiagnosticSpec(Id: "SCRIBE200")); + + /// + /// Explicit-spec form of . Use this + /// when authoring in fluent form and you want to pin a stable diagnostic id. + /// + /// Positive predicate evaluated against the matched type's focus. + /// + /// Diagnostic descriptor. is required; + /// other fields fall back to sensible defaults (title "Where predicate", + /// message "Where predicate on '{0}' was not satisfied", severity + /// ). + /// + public TypeShape Where(Func predicate, DiagnosticSpec spec) + { + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (string.IsNullOrEmpty(spec.Id)) + { + throw new ArgumentException( + "DiagnosticSpec.Id is required on Where(...) — the query author must pick a stable diagnostic id.", + nameof(spec)); + } + + return Check( + predicate: (symbol, _, _) => + { + var focus = new TypeFocus( + symbol: symbol, + fqn: InternPool.Intern(symbol.ToDisplayString()), + origin: LocationInfo.From(FirstLocation(symbol))); + return predicate(focus); + }, + id: spec.Id!, + title: spec.Title ?? "Where predicate", + message: spec.Message ?? "Where predicate on '{0}' was not satisfied", + severity: spec.Severity ?? DiagnosticSeverity.Warning, + squiggle: spec.Target ?? SquiggleAt.Identifier); + } + + private static Location? FirstLocation(INamedTypeSymbol symbol) + { + var locations = symbol.Locations; + return locations.Length == 0 ? null : locations[0]; + } +} diff --git a/Scribe/Shapes/ShapeBuilder.cs b/Scribe/Shapes/TypeShape.cs similarity index 71% rename from Scribe/Shapes/ShapeBuilder.cs rename to Scribe/Shapes/TypeShape.cs index dee081a..a7142ea 100644 --- a/Scribe/Shapes/ShapeBuilder.cs +++ b/Scribe/Shapes/TypeShape.cs @@ -11,53 +11,337 @@ namespace Scribe.Shapes; /// -/// Fluent builder that accumulates a conjunction of MustBeX checks on a single -/// type-shape subject and projects the match into a cache-safe TModel. +/// Focused authoring Shape over a type declaration. Accumulates a conjunction of +/// MustBeX checks and seals the match into a cache-safe TModel via +/// . /// /// -/// Obtain via the factories on . +/// Obtain via the Expose* factories on . /// Each primitive is a declarative constraint: the check carries a default /// diagnostic ID, title, message, severity, squiggle anchor, and fix hint; callers /// can selectively override any of these via a . /// -public sealed partial class ShapeBuilder +public sealed partial class TypeShape { private readonly TypeKindFilter _kind; private readonly List _checks = new(); private readonly List _memberChecks = new(); + private readonly List> _lensBranches = new(); private string? _primaryAttributeMetadataName; private string? _primaryInterfaceMetadataName; - internal ShapeBuilder(TypeKindFilter kind) => _kind = kind; + internal TypeShape(TypeKindFilter kind) => _kind = kind; internal TypeKindFilter Kind => _kind; internal IReadOnlyList Checks => _checks; internal IReadOnlyList MemberChecks => _memberChecks; + internal IReadOnlyList> LensBranches => _lensBranches; internal string? PrimaryAttributeMetadataName => _primaryAttributeMetadataName; internal string? PrimaryInterfaceMetadataName => _primaryInterfaceMetadataName; + internal void AddLensBranch(ILensBranch branch) => + _lensBranches.Add(branch ?? throw new ArgumentNullException(nameof(branch))); + /// - /// Seal the builder and project matches into a cache-safe model. - /// The projection runs inside the incremental pipeline's transform stage, with + /// Compose this with one or more sibling alternatives + /// into a . The caller can then seal the + /// disjunction with . + /// + /// Additional alternatives. At least one must be supplied. + public OneOfTypeShape OneOf(params TypeShape[] others) + { + if (others is null || others.Length == 0) + { + throw new ArgumentException("OneOf requires at least one sibling alternative.", nameof(others)); + } + + var all = new TypeShape[others.Length + 1]; + all[0] = this; + Array.Copy(others, 0, all, 1, others.Length); + return new OneOfTypeShape(all, fusionSpec: null); + } + + /// + /// Overload of that accepts a + /// override for the fused diagnostic. + /// + public OneOfTypeShape OneOf(DiagnosticSpec fusionSpec, params TypeShape[] others) + { + if (others is null || others.Length == 0) + { + throw new ArgumentException("OneOf requires at least one sibling alternative.", nameof(others)); + } + + var all = new TypeShape[others.Length + 1]; + all[0] = this; + Array.Copy(others, 0, all, 1, others.Length); + return new OneOfTypeShape(all, fusionSpec); + } + + /// + /// Etch the authoring chain into a sealed , permanently + /// committing the accumulated predicates, lenses, and member checks and producing + /// a cache-safe for each surviving match. + /// The etch callback runs inside the incremental pipeline's transform stage, with /// access to the matched symbol, semantic model, and — if declared — the /// driving attribute reader. /// /// Must be for cache correctness. - public Shape Project(ProjectionDelegate project) + public Shape Etch(EtchDelegate etch) where TModel : IEquatable { - if (project is null) + if (etch is null) { - throw new ArgumentNullException(nameof(project)); + throw new ArgumentNullException(nameof(etch)); } return new Shape( kind: _kind, checks: _checks.ToArray(), memberChecks: _memberChecks.ToArray(), + lensBranches: _lensBranches.ToArray(), primaryAttributeMetadataName: _primaryAttributeMetadataName, primaryInterfaceMetadataName: _primaryInterfaceMetadataName, - project: project); + etch: etch); + } + + /// + /// Enter the attributes lens: navigate every attribute application on the matched + /// type whose class FQN equals , optionally + /// declaring further predicates or sub-lens hops through the + /// callback. Declares a presence constraint when + /// / are supplied. + /// + /// Fully-qualified attribute class name. Open-generic forms are matched by the bare name before <. + /// Optional callback receiving the nested for per-application checks and deeper navigation. + /// Minimum number of attribute applications required on the type. 0 (default) disables the lower bound. + /// Maximum allowed. (default) disables the upper bound. + /// Override for the presence-violation diagnostic descriptor. + /// How nested-check results aggregate across navigated attribute applications — (default) emits per-child violations; requires at least one application to pass; requires every application to fail. + /// Override for the aggregate diagnostic emitted when is or . Ignored for . + public TypeShape Attributes( + string attributeFqn, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (string.IsNullOrEmpty(attributeFqn)) + { + throw new ArgumentException("Attribute FQN must not be empty.", nameof(attributeFqn)); + } + + if (min < 0) + { + throw new ArgumentOutOfRangeException(nameof(min), "Minimum count must be non-negative."); + } + + if (max is { } m && m < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Maximum count must be at least the minimum."); + } + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.Attributes(attributeFqn); + + LensPresenceSpec? presence = null; + var hasPresence = min > 0 || max is not null || presenceSpec is not null; + if (hasPresence) + { + presence = new LensPresenceSpec( + Id: InternPool.Intern(presenceSpec?.Id ?? "SCRIBE050"), + Title: presenceSpec?.Title ?? "Attribute presence constraint", + MessageFormat: presenceSpec?.Message + ?? "Expected [{0}..{1}] applications of attribute '" + attributeFqn + "', observed {2}", + Severity: presenceSpec?.Severity ?? DiagnosticSeverity.Error); + } + + var quantifierDescriptor = BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE092" : "SCRIBE093", + defaultMessage: quantifier == Quantifier.Any + ? "At least one application of attribute '" + attributeFqn + "' must satisfy the required checks" + : "No application of attribute '" + attributeFqn + "' may satisfy the disallowed checks"); + + _lensBranches.Add(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor, + HopDescription: "Attributes(\"" + attributeFqn + "\")")); + + return this; + } + + internal static LensQuantifierSpec? BuildQuantifierSpec( + Quantifier quantifier, + DiagnosticSpec? spec, + string defaultId, + string defaultMessage) + { + if (quantifier == Quantifier.All) + { + return null; + } + + return new LensQuantifierSpec( + Id: InternPool.Intern(spec?.Id ?? defaultId), + Title: spec?.Title ?? (quantifier == Quantifier.Any + ? "Any-quantifier aggregate" + : "None-quantifier aggregate"), + MessageFormat: spec?.Message ?? defaultMessage, + Severity: spec?.Severity ?? DiagnosticSeverity.Error); + } + + /// + /// Enter the members lens: navigate every directly-declared member of the + /// matched type in source order, optionally filtered by + /// (field, property, method, event, nested type). + /// Declares a presence constraint when / + /// are supplied. + /// + /// Restrict navigation to one member kind. (default) navigates every declared member regardless of kind. + /// Optional callback receiving the nested for per-member checks. + /// Minimum member count required on the type. + /// Maximum member count allowed. disables the upper bound. + /// Override for the presence-violation diagnostic descriptor. + /// How nested-check results aggregate across navigated members — (default) emits per-child violations; requires at least one member to pass; requires every member to fail. + /// Override for the aggregate diagnostic emitted when is or . Ignored for . + public TypeShape Members( + SymbolKind? kind = null, + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (min < 0) + { + throw new ArgumentOutOfRangeException(nameof(min), "Minimum count must be non-negative."); + } + + if (max is { } m && m < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Maximum count must be at least the minimum."); + } + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.Members(kind); + + LensPresenceSpec? presence = null; + var hasPresence = min > 0 || max is not null || presenceSpec is not null; + var kindDescription = kind?.ToString() ?? "any kind"; + if (hasPresence) + { + presence = new LensPresenceSpec( + Id: InternPool.Intern(presenceSpec?.Id ?? "SCRIBE051"), + Title: presenceSpec?.Title ?? "Member presence constraint", + MessageFormat: presenceSpec?.Message + ?? "Expected [{0}..{1}] members of " + kindDescription + ", observed {2}", + Severity: presenceSpec?.Severity ?? DiagnosticSeverity.Error); + } + + var quantifierDescriptor = BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE090" : "SCRIBE091", + defaultMessage: quantifier == Quantifier.Any + ? "At least one member of " + kindDescription + " must satisfy the required checks" + : "No member of " + kindDescription + " may satisfy the disallowed checks"); + + _lensBranches.Add(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor, + HopDescription: kind is null ? "Members" : "Members(" + kindDescription + ")")); + + return this; + } + + /// + /// Enter the base-type-chain lens: navigate the matched type's inheritance + /// chain from immediate base up to (but excluding) . + /// Each step carries its depth — 0 = immediate base, 1 = + /// grandparent, and so on. Declares a presence constraint when + /// / are supplied; + /// min: 1 means "must inherit from a non- base", + /// max: 0 means "must inherit directly from ". + /// + /// Optional callback receiving the nested for per-step checks. + /// Minimum chain length required. + /// Maximum chain length allowed. disables the upper bound. + /// Override for the presence-violation diagnostic descriptor. + /// How nested-check results aggregate across base-type-chain steps — (default) emits per-child violations; requires at least one step to pass; requires every step to fail. + /// Override for the aggregate diagnostic emitted when is or . Ignored for . + public TypeShape BaseTypeChain( + Action>? configure = null, + int min = 0, + int? max = null, + DiagnosticSpec? presenceSpec = null, + Quantifier quantifier = Quantifier.All, + DiagnosticSpec? quantifierSpec = null) + { + if (min < 0) + { + throw new ArgumentOutOfRangeException(nameof(min), "Minimum count must be non-negative."); + } + + if (max is { } m && m < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Maximum count must be at least the minimum."); + } + + var nested = new FocusShape(); + configure?.Invoke(nested); + var lens = Lenses.BuiltinLenses.BaseTypeChain(); + + LensPresenceSpec? presence = null; + var hasPresence = min > 0 || max is not null || presenceSpec is not null; + if (hasPresence) + { + presence = new LensPresenceSpec( + Id: InternPool.Intern(presenceSpec?.Id ?? "SCRIBE052"), + Title: presenceSpec?.Title ?? "Base-type-chain length constraint", + MessageFormat: presenceSpec?.Message + ?? "Expected [{0}..{1}] base-type-chain steps, observed {2}", + Severity: presenceSpec?.Severity ?? DiagnosticSeverity.Error); + } + + var quantifierDescriptor = BuildQuantifierSpec( + quantifier, + quantifierSpec, + defaultId: quantifier == Quantifier.Any ? "SCRIBE094" : "SCRIBE095", + defaultMessage: quantifier == Quantifier.Any + ? "At least one base-type-chain step must satisfy the required checks" + : "No base-type-chain step may satisfy the disallowed checks"); + + _lensBranches.Add(new LensBranch( + Lens: lens, + Nested: nested, + MinCount: min, + MaxCount: max, + Presence: presence, + ParentOrigin: parent => parent.Origin, + Quantifier: quantifier, + QuantifierSpec: quantifierDescriptor, + HopDescription: "BaseTypeChain")); + + return this; } /// @@ -75,7 +359,7 @@ public Shape Project(ProjectionDelegate project) /// fast-path equivalent to ForAttributeWithMetadataName exists for /// interface implementation. /// - public ShapeBuilder Implementing(string metadataName) + public TypeShape Implementing(string metadataName) { if (string.IsNullOrEmpty(metadataName)) { @@ -91,7 +375,7 @@ public ShapeBuilder Implementing(string metadataName) /// interface's closed form is known at shape-declaration time; for open /// generics (e.g. IFoo<>), supply the metadata name directly. /// - public ShapeBuilder Implementing() + public TypeShape Implementing() where T : class => Implementing(typeof(T).FullName!); @@ -144,9 +428,9 @@ private void AddCheck( Severity: spec?.Severity ?? defaultSeverity, SquiggleAt: spec?.Target ?? defaultSquiggle, FixKind: spec?.Fix?.Kind ?? defaultFix, - Predicate: predicate, - MessageArgs: messageArgs, - FixProperties: fixProperties)); + Predicate: (focus, comp, ct) => predicate(focus.Symbol, comp, ct), + MessageArgs: focus => messageArgs(focus.Symbol), + FixProperties: fixProperties is null ? null : focus => fixProperties(focus.Symbol))); } // ─────────────────────────────────────────────────────────────── @@ -154,7 +438,7 @@ private void AddCheck( // ─────────────────────────────────────────────────────────────── /// Require the type to be declared partial. - public ShapeBuilder MustBePartial(DiagnosticSpec? spec = null) + public TypeShape MustBePartial(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE001", @@ -170,7 +454,7 @@ public ShapeBuilder MustBePartial(DiagnosticSpec? spec = null) } /// Require the type to be declared sealed. - public ShapeBuilder MustBeSealed(DiagnosticSpec? spec = null) + public TypeShape MustBeSealed(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE005", @@ -190,7 +474,7 @@ public ShapeBuilder MustBeSealed(DiagnosticSpec? spec = null) /// only in v1 — use the overload /// with a metadata name for generic interfaces. /// - public ShapeBuilder MustImplement(DiagnosticSpec? spec = null) + public TypeShape MustImplement(DiagnosticSpec? spec = null) where T : class { var fqn = typeof(T).FullName!; @@ -198,7 +482,7 @@ public ShapeBuilder MustImplement(DiagnosticSpec? spec = null) } /// Require the type to implement the interface named by . - public ShapeBuilder MustImplement(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustImplement(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -227,14 +511,14 @@ public ShapeBuilder MustImplement(string metadataName, DiagnosticSpec? spec = nu /// attribute and routes collection through /// . /// - public ShapeBuilder MustHaveAttribute(DiagnosticSpec? spec = null) + public TypeShape MustHaveAttribute(DiagnosticSpec? spec = null) where T : System.Attribute => MustHaveAttribute(typeof(T).FullName!, spec); /// /// Require the type to carry the attribute named by . /// - public ShapeBuilder MustHaveAttribute(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustHaveAttribute(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -265,7 +549,7 @@ public ShapeBuilder MustHaveAttribute(string metadataName, DiagnosticSpec? spec /// does not uniquely identify a target name; override via /// with a concrete to supply one. /// - public ShapeBuilder MustBeNamed(string pattern, DiagnosticSpec? spec = null) + public TypeShape MustBeNamed(string pattern, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(pattern)) { @@ -287,7 +571,7 @@ public ShapeBuilder MustBeNamed(string pattern, DiagnosticSpec? spec = null) } /// Require the type to be declared abstract. - public ShapeBuilder MustBeAbstract(DiagnosticSpec? spec = null) + public TypeShape MustBeAbstract(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE015", @@ -303,7 +587,7 @@ public ShapeBuilder MustBeAbstract(DiagnosticSpec? spec = null) } /// Require the type to be declared static. - public ShapeBuilder MustBeStatic(DiagnosticSpec? spec = null) + public TypeShape MustBeStatic(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE017", @@ -322,14 +606,14 @@ public ShapeBuilder MustBeStatic(DiagnosticSpec? spec = null) /// Require the type to extend the base class . /// Interfaces use instead. /// - public ShapeBuilder MustExtend(DiagnosticSpec? spec = null) + public TypeShape MustExtend(DiagnosticSpec? spec = null) where T : class => MustExtend(typeof(T).FullName!, spec); /// /// Require the type to extend the base class named by . /// - public ShapeBuilder MustExtend(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustExtend(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -357,7 +641,7 @@ public ShapeBuilder MustExtend(string metadataName, DiagnosticSpec? spec = null) /// as a regular expression. No auto-fix is offered — moving a file is outside /// Scribe's automation surface. /// - public ShapeBuilder MustBeInNamespace(string pattern, DiagnosticSpec? spec = null) + public TypeShape MustBeInNamespace(string pattern, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(pattern)) { @@ -382,7 +666,7 @@ public ShapeBuilder MustBeInNamespace(string pattern, DiagnosticSpec? spec = nul } /// Forbid the abstract modifier on the type. - public ShapeBuilder MustNotBeAbstract(DiagnosticSpec? spec = null) + public TypeShape MustNotBeAbstract(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE016", @@ -398,7 +682,7 @@ public ShapeBuilder MustNotBeAbstract(DiagnosticSpec? spec = null) } /// Forbid generic type parameters on the type. - public ShapeBuilder MustNotBeGeneric(DiagnosticSpec? spec = null) + public TypeShape MustNotBeGeneric(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE024", @@ -414,14 +698,14 @@ public ShapeBuilder MustNotBeGeneric(DiagnosticSpec? spec = null) } /// Forbid implementation of interface . - public ShapeBuilder MustNotImplement(DiagnosticSpec? spec = null) + public TypeShape MustNotImplement(DiagnosticSpec? spec = null) where T : class => MustNotImplement(typeof(T).FullName!, spec); /// /// Forbid implementation of the interface named by . /// - public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustNotImplement(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -449,7 +733,7 @@ public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec = // ─────────────────────────────────────────────────────────────── /// Forbid the partial modifier on the type. - public ShapeBuilder MustNotBePartial(DiagnosticSpec? spec = null) + public TypeShape MustNotBePartial(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE002", @@ -465,7 +749,7 @@ public ShapeBuilder MustNotBePartial(DiagnosticSpec? spec = null) } /// Forbid the sealed modifier on the type. - public ShapeBuilder MustNotBeSealed(DiagnosticSpec? spec = null) + public TypeShape MustNotBeSealed(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE006", @@ -483,12 +767,12 @@ public ShapeBuilder MustNotBeSealed(DiagnosticSpec? spec = null) } /// Forbid attribute on the type. - public ShapeBuilder MustNotHaveAttribute(DiagnosticSpec? spec = null) + public TypeShape MustNotHaveAttribute(DiagnosticSpec? spec = null) where T : System.Attribute => MustNotHaveAttribute(typeof(T).FullName!, spec); /// Forbid the attribute named by . - public ShapeBuilder MustNotHaveAttribute(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustNotHaveAttribute(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -515,7 +799,7 @@ public ShapeBuilder MustNotHaveAttribute(string metadataName, DiagnosticSpec? sp /// Forbid the type's from matching . /// No auto-fix — renaming is a cross-file operation outside Scribe's automation surface. /// - public ShapeBuilder MustNotBeNamed(string pattern, DiagnosticSpec? spec = null) + public TypeShape MustNotBeNamed(string pattern, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(pattern)) { @@ -537,7 +821,7 @@ public ShapeBuilder MustNotBeNamed(string pattern, DiagnosticSpec? spec = null) } /// Forbid the static modifier on the type. - public ShapeBuilder MustNotBeStatic(DiagnosticSpec? spec = null) + public TypeShape MustNotBeStatic(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE018", @@ -553,12 +837,12 @@ public ShapeBuilder MustNotBeStatic(DiagnosticSpec? spec = null) } /// Forbid the type from extending . - public ShapeBuilder MustNotExtend(DiagnosticSpec? spec = null) + public TypeShape MustNotExtend(DiagnosticSpec? spec = null) where T : class => MustNotExtend(typeof(T).FullName!, spec); /// Forbid the type from extending the base class named by . - public ShapeBuilder MustNotExtend(string metadataName, DiagnosticSpec? spec = null) + public TypeShape MustNotExtend(string metadataName, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(metadataName)) { @@ -585,7 +869,7 @@ public ShapeBuilder MustNotExtend(string metadataName, DiagnosticSpec? spec = nu /// Forbid the type's containing namespace from matching . /// No auto-fix — moving a file is outside Scribe's automation surface. /// - public ShapeBuilder MustNotBeInNamespace(string pattern, DiagnosticSpec? spec = null) + public TypeShape MustNotBeInNamespace(string pattern, DiagnosticSpec? spec = null) { if (string.IsNullOrEmpty(pattern)) { @@ -610,7 +894,7 @@ public ShapeBuilder MustNotBeInNamespace(string pattern, DiagnosticSpec? spec = } /// Require the type to declare at least one generic type parameter. - public ShapeBuilder MustBeGeneric(DiagnosticSpec? spec = null) + public TypeShape MustBeGeneric(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE023", @@ -630,7 +914,7 @@ public ShapeBuilder MustBeGeneric(DiagnosticSpec? spec = null) // ─────────────────────────────────────────────────────────────── /// Require the type to be declared public. - public ShapeBuilder MustBePublic(DiagnosticSpec? spec = null) + public TypeShape MustBePublic(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE011", @@ -648,7 +932,7 @@ public ShapeBuilder MustBePublic(DiagnosticSpec? spec = null) } /// Require the type to be declared internal. - public ShapeBuilder MustBeInternal(DiagnosticSpec? spec = null) + public TypeShape MustBeInternal(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE013", @@ -669,7 +953,7 @@ public ShapeBuilder MustBeInternal(DiagnosticSpec? spec = null) /// Require a nested type to be declared private. Top-level types cannot be /// private — use for that case. /// - public ShapeBuilder MustBePrivate(DiagnosticSpec? spec = null) + public TypeShape MustBePrivate(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE025", @@ -687,7 +971,7 @@ public ShapeBuilder MustBePrivate(DiagnosticSpec? spec = null) } /// Forbid the public visibility modifier on the type. - public ShapeBuilder MustNotBePublic(DiagnosticSpec? spec = null) + public TypeShape MustNotBePublic(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE012", @@ -703,7 +987,7 @@ public ShapeBuilder MustNotBePublic(DiagnosticSpec? spec = null) } /// Forbid the internal visibility modifier on the type. - public ShapeBuilder MustNotBeInternal(DiagnosticSpec? spec = null) + public TypeShape MustNotBeInternal(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE014", @@ -719,7 +1003,7 @@ public ShapeBuilder MustNotBeInternal(DiagnosticSpec? spec = null) } /// Forbid the private visibility modifier on the type. - public ShapeBuilder MustNotBePrivate(DiagnosticSpec? spec = null) + public TypeShape MustNotBePrivate(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE026", @@ -739,7 +1023,7 @@ public ShapeBuilder MustNotBePrivate(DiagnosticSpec? spec = null) // ─────────────────────────────────────────────────────────────── /// Require the type to be a record (class-record or record-struct). - public ShapeBuilder MustBeRecord(DiagnosticSpec? spec = null) + public TypeShape MustBeRecord(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE019", @@ -755,7 +1039,7 @@ public ShapeBuilder MustBeRecord(DiagnosticSpec? spec = null) } /// Forbid the record keyword on the type. - public ShapeBuilder MustNotBeRecord(DiagnosticSpec? spec = null) + public TypeShape MustNotBeRecord(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE020", @@ -771,7 +1055,7 @@ public ShapeBuilder MustNotBeRecord(DiagnosticSpec? spec = null) } /// Require the type to be a value type (struct or record struct). - public ShapeBuilder MustBeValueType(DiagnosticSpec? spec = null) + public TypeShape MustBeValueType(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE021", @@ -792,7 +1076,7 @@ public ShapeBuilder MustBeValueType(DiagnosticSpec? spec = null) /// No auto-fix — rewriting to a record struct changes the type's identity and /// semantics enough that the repair is left to the user. /// - public ShapeBuilder MustBeRecordStruct(DiagnosticSpec? spec = null) + public TypeShape MustBeRecordStruct(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE032", @@ -813,7 +1097,7 @@ public ShapeBuilder MustBeRecordStruct(DiagnosticSpec? spec = null) /// non-value-types pass this check. Pair with /// or a kind filter if you want a non-struct to also be rejected. /// - public ShapeBuilder MustBeReadOnly(DiagnosticSpec? spec = null) + public TypeShape MustBeReadOnly(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE033", @@ -834,7 +1118,7 @@ public ShapeBuilder MustBeReadOnly(DiagnosticSpec? spec = null) /// consequences (accessibility, references, file layout) for Scribe to /// automate. /// - public ShapeBuilder MustNotBeNested(DiagnosticSpec? spec = null) + public TypeShape MustNotBeNested(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE034", @@ -850,7 +1134,7 @@ public ShapeBuilder MustNotBeNested(DiagnosticSpec? spec = null) } /// Forbid the type from being a value type. - public ShapeBuilder MustNotBeValueType(DiagnosticSpec? spec = null) + public TypeShape MustNotBeValueType(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE022", @@ -875,7 +1159,7 @@ public ShapeBuilder MustNotBeValueType(DiagnosticSpec? spec = null) /// value types the default ctor is always present. Primary constructors on /// records and structs are not considered parameterless. /// - public ShapeBuilder MustHaveParameterlessConstructor(DiagnosticSpec? spec = null) + public TypeShape MustHaveParameterlessConstructor(DiagnosticSpec? spec = null) { AddCheck( defaultId: "SCRIBE031", @@ -894,7 +1178,7 @@ public ShapeBuilder MustHaveParameterlessConstructor(DiagnosticSpec? spec = null // Predicate helpers (static, closure-free) // ─────────────────────────────────────────────────────────────── - private static bool IsPartial(INamedTypeSymbol symbol, CancellationToken ct) + internal static bool IsPartial(INamedTypeSymbol symbol, CancellationToken ct) { var refs = symbol.DeclaringSyntaxReferences; foreach (var reference in refs) @@ -983,7 +1267,7 @@ private static bool HasPublicParameterlessCtor(INamedTypeSymbol symbol) return false; } - private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) + internal static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) { foreach (var attribute in symbol.GetAttributes()) { @@ -1033,7 +1317,7 @@ private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) /// Additional message arguments beyond the type name ({1}, {2}, ...). /// Optional — default yields just the type name as {0}. /// - public ShapeBuilder Check( + public TypeShape Check( Func predicate, string id, string title, @@ -1069,8 +1353,8 @@ public ShapeBuilder Check( Severity: severity, SquiggleAt: squiggle, FixKind: fix, - Predicate: predicate, - MessageArgs: args, + Predicate: (focus, comp, ct) => predicate(focus.Symbol, comp, ct), + MessageArgs: focus => args(focus.Symbol), FixProperties: fixProps is null ? null : _ => fixProps)); return this; } @@ -1096,7 +1380,7 @@ public ShapeBuilder Check( /// Offender predicate — reports a diagnostic. /// Diagnostic descriptor. /// Message argument builder. {0} defaults to the type name; the user supplies any remaining args (typically the member name). - public ShapeBuilder ForEachMember( + public TypeShape ForEachMember( Func match, MemberDiagnosticSpec spec, Func>? messageArgs = null) diff --git a/Scribe/build/Scribe.LocalDev.targets b/Scribe/build/Scribe.LocalDev.targets index c5f33c8..656668c 100644 --- a/Scribe/build/Scribe.LocalDev.targets +++ b/Scribe/build/Scribe.LocalDev.targets @@ -79,7 +79,8 @@ and '$(ScribesName)' != '' and '$(PackageId)' != '' and '$(ScribeArtifactsDir)' != '' - and '$(IsPackable)' != 'false'" + and '$(IsPackable)' != 'false' + and '$(_IsScribeMeta)' != 'true'" > <_ScribeDevVersion>$(NuGetPackageVersion) diff --git a/docs/design-member-level-shapes.md b/docs/design-member-level-shapes.md index 415838e..a4222bc 100644 --- a/docs/design-member-level-shapes.md +++ b/docs/design-member-level-shapes.md @@ -1,343 +1,404 @@ -# Design — Member-Level Shape Rules (the "WORD1005 problem") +# Design — Navigable Shape Composition -> Draft. Captures the design space for extending the Shape DSL from type-level-only -> checks to rules that iterate declared members and emit per-member diagnostics, -> paired with fixers that operate on the specific member syntax. +> Draft. Captures the next major evolution of the Shape DSL: from flat +> predicates on a single type to composable navigation across the C# +> declaration graph, with validation and projection at every hop. +> +> Supersedes the earlier draft on member-level shapes — that phase has +> shipped (see **Status** below). --- -## Why +## Status -The Shape DSL today is strictly type-level: +What the Shape DSL can already do: -- `ShapeCheck.Predicate(INamedTypeSymbol, Compilation, CancellationToken) -> bool` — one verdict per type. -- `SquiggleAt` locates on the type (identifier / keyword / attribute / full decl). -- `MessageArgs(INamedTypeSymbol) -> EquatableArray` — one message per type. -- `IShapeFix.FixAsync` receives the `TypeDeclarationSyntax`, not a specific member. +| Primitive | Status | Notes | +| --------- | ------ | ----- | +| Type-level predicates (`MustBePartial`, `MustBeSealed`, `MustBeRecord`, `MustBeReadOnly`, `MustBeRecordStruct`, `MustNotBeNested`, `Implementing`, …) | Shipped | Surface: `Stencil.ExposeAnyType() / Stencil.ExposeRecord() / …` → fluent chain → `Project`. | +| Projection to equatable model | Shipped | `ShapedSymbol` with `Fqn`, `Model`, `Location`, `Violations`. Drives both `ToAnalyzer()` and `ToProvider(context)`. | +| Member-level rules (`ForEachMember`) | Shipped | `MemberCheck`, `MemberDiagnosticSpec`, `MemberSquiggleAt`, `MemberSquiggleLocator`. Emits zero-or-more diagnostics per type, squiggled at the member. | +| Fix kinds | Shipped | Twenty-one built-in `FixKind`s covering modifiers, base list, attributes, visibility, constructors. Plus `FixKind.Custom` + Ink's `WithCustomFix(tag, delegate)` for bespoke rewrites. | +| Stream-to-stream join | Shipped | `Prism.By` joins two shape streams by string key, surfaces orphan diagnostics (`RequireLeftHasRight`, `WarnOnRightUnused`). | -That's sufficient for the seventeen current `FixKind`s — all of them modify the -type declaration itself (modifiers, name, base list, attributes, containing -namespace). But a recurring real-world rule shape does not fit: +What the DSL **cannot** yet do, and what this document is about: -> "Types matching this shape must not contain any declared member satisfying -> predicate P. Report one diagnostic per offending member, squiggled at that -> member, with the member name in the message. Each violation can be fixed -> independently." +- Navigate from a type into its **attributes**, the **type arguments of those + attributes**, the **constructor arguments of those attributes**, or its + **base-type chain** — and apply a sub-Shape at the new focus. +- Express cross-type rules like "the `T` in `[ComposedOf]` must implement + `IKeyPart`" without dropping back to imperative Roslyn. +- Report violations at the **correct source span** for nested navigations + (today violations fire at the type identifier or member identifier — there + is no "at the type argument of the second `[Foo]` attribute on type X"). -The canonical instance in the wild is Hermetic's **WORD1005** — `IIdentifier` -implementations must not declare instance properties or fields beyond `Value`. -The existing fixer ([IdentifierExtraMembersFix.cs][extra-fixer]) rewrites each -offender into a static extension method on a generated `implicit extension` -type. More will follow (e.g. Hermetic's planned "aggregate roots must not -expose public mutable state" rule). - -Without a DSL path, these rules fall back to hand-written -`DiagnosticAnalyzer` + `CodeFixProvider` pairs, which is exactly the friction -Shape was built to eliminate. - -[extra-fixer]: ../../Hermetic/Hermetic.Logos.Fixes/Essence/IdentifierExtraMembersFix.cs +`Prism.By` covers one shape of cross-type work — joining two independent +streams by equal keys. Navigable composition covers the orthogonal shape — +following a symbol's structure (attributes, type args, ancestors, members) to +a related symbol in-place. --- -## Problem statement - -A single Shape must be able to declare a **member-level check** that: - -1. Iterates over declared members of the matched type (filterable by symbol kind, - accessibility, static-ness, name, attribute, etc.). -2. Emits **zero or more** `DiagnosticInfo` per type — one per matching member. -3. Squiggles at the **member** location, not the type. -4. Carries **per-member** message arguments (typically the member name). -5. Is paired with a fixer that receives the offending `MemberDeclarationSyntax` - (or at minimum the member's `ISymbol`) so it can rewrite that specific node - — not the whole type. - -All of the above while preserving Shape's invariants: - -- Incremental-generator-safe equality on projection models. -- Fluent, declarative call-site ergonomics (no raw Roslyn in user code for the - common case). -- One `Shape` → one analyzer + one fix provider (no parallel class - hierarchies for "member rules"). +## The class of problems + +Hand-rolled Roslyn analyzers repeatedly solve instances of the same pattern: + +> "Given a type matching shape A, for each of its `[Foo]` attributes, `T` +> must satisfy shape B. Report the failure at `T`'s attribute argument." + +Same structure, different names, across dozens of frameworks: + +- **Hermetic Event Contract** — `Command` with `[RaisesEvent]` ↔ `Event` + with `[AppliedBy]` ↔ `Aggregate` with `[Applies]`. Bidirectional + cycle of joins, each with its own predicate. Currently ~400 lines of + imperative Roslyn. +- **Hermetic HierarchicalKey** — `[ComposedOf]` constrained to + `IKeyPart`, `[Discriminator]` requires `[KeyPart]` ancestor, + `[Param(name, type)].type` must be parsable. Currently ~400 lines. +- **EF Core** — `[Index(nameof(X))]` — `X` must be a property on the same + entity. `[ForeignKey(nameof(X))]` — `X` must be a navigation property + whose type has a keyed property. +- **MediatR / MassTransit** — handler type discovery, `IRequestHandler` + binding, saga state-machine transitions. +- **ASP.NET routing** — `[Route]` templates referencing action parameters by + name, `[FromServices]` resolved against the DI container shape. +- **FluentValidation** — `RuleFor(x => x.Foo).SetValidator(new FooValidator())` + — validator type must match property type. + +Every one of these is **declarative in intent** ("constrain this attribute +argument to this shape") and **imperative in implementation** (manual symbol +walks, attribute resolution, location bookkeeping, cache-correctness +pitfalls). The Shape DSL's promise is that the intent should match the +implementation. --- -## Design axes +## Core insight — the DSL is relational algebra over declarations -### 1. DSL surface — how the user declares the rule +The existing fluent Shape API describes **filters and projections** over a +single relation (types-in-compilation). What's missing is **joins**: ways to +relate that stream to other streams (attributes, type arguments, members, +ancestors) by declarative navigation. -Three candidate shapes, in order of increasing flexibility: +Each navigation is a lens — a `SelectMany` over the symbol graph: -**A. Sugar primitives** — bake specific member patterns into dedicated builders. - -```csharp -Shape.RecordStruct() - .MustImplement("Hermetic.IIdentifier") - .MustNotDeclareInstancePropertiesOrFieldsExcept("Value", - spec: new DiagnosticSpec("WORD1005", ...)); +```text +Shape + .SelectMany(src → IEnumerable) // the lens — one-to-many projection + .Shape(target → Shape) // sub-predicate at focus ``` -Pros: zero generality cost, totally declarative, easy to read. -Cons: every new use case demands a new primitive; won't scale past ~3 rules -before the builder surface becomes a zoo. - -**B. General `ForEachMember` iterator** — one primitive covering the whole space. - -```csharp -Shape.RecordStruct() - .MustImplement("Hermetic.IIdentifier") - .ForEachMember( - match: m => m is IPropertySymbol or IFieldSymbol - && !m.IsStatic - && m.Name != "Value", - report: new MemberDiagnosticSpec( - id: "WORD1005", - severity: DiagnosticSeverity.Error, - messageFormat: "Record struct '{0}' has extra instance member '{1}'", - messageArgs: (type, member) => [type.Name, member.Name], - squiggleAt: MemberSquiggleAt.Identifier, - fixKind: FixKind.CustomMemberFix)); -``` - -Pros: one primitive, composes (`ForEachMember(...).ForEachMember(...)`), easy -to grow with new selectors. Cons: the user writes a predicate lambda per rule. -That's fine — the existing type-level primitives also accept predicates -internally, they just wrap common patterns. - -**C. Both** — sugar primitives are implemented on top of `ForEachMember`. - -This is almost certainly where we land. `MustNotDeclareField`, -`MustNotDeclareMutableProperty`, etc. collapse to `ForEachMember` calls. The -question is which sugar is worth adding up front; answer: none, until a -second WORD1005-shaped rule appears. - -**Recommendation:** ship **B** first. Sugar is additive and non-breaking. - -### 2. Check execution — from `bool` to `IEnumerable` - -Today `ShapeCheck` is: - -```csharp -internal sealed record ShapeCheck( - string Id, ..., Func Predicate, - Func> MessageArgs, ...); -``` - -Two refactors on the table: - -**A. Two check kinds** — keep `ShapeCheck` (type-level), add `MemberCheck`: +Three things ride along every hop: -```csharp -internal abstract record CheckBase; - -internal sealed record TypeCheck( - string Id, ..., - Func Predicate, - Func> MessageArgs) : CheckBase; - -internal sealed record MemberCheck( - string Id, ..., - Func MemberMatch, // runs per declared member - Func> MessageArgs, - MemberSquiggleAt SquiggleAt) : CheckBase; -``` +1. **Predicate** — pass / fail becomes a violation. +2. **Projection** — contributes to the aggregate model at the leaf. +3. **Location** — propagates the source span so the violation squiggles at + the correct node (the attribute argument, the type-arg, the member, the + base-list entry), not the root type. -`Shape.RunChecks` dispatches on check kind. Clean, open for more kinds -(e.g. a future "cross-symbol relation" check). +Arbitrary nesting depth, same grammar at every level. A Shape doesn't know or +care whether it's at depth 1 or depth 5. -**B. Unified `IEnumerable` return** — one primitive: +Equivalently, in LINQ query-comprehension form: ```csharp -internal sealed record ShapeCheck( - string Id, ..., - Func> Evaluate, - ...); +from type in Stencil.ExposeAnyType().Implementing(ICommandLaw) +from raises in type.Attributes(RaisesEvent) +from evt in raises.GenericTypeArg(0).AsTypeShape() +from applied in evt.Attributes(AppliedBy) +from agg in applied.GenericTypeArg(0).AsTypeShape() +where agg.HasMember(m => m.HasAttribute(Applies, tArg => tArg == evt)) +select new CommandEventContract(type, evt, agg); ``` -`ViolationSite` carries the squiggle location + per-violation message args. -A type-level check yields zero or one site; a member-level check yields N. - -Pros: truly one primitive; good orthogonality. -Cons: every existing type-level predicate grows a yielding wrapper. Allocation -overhead per check call — today the hot path is `bool`, no allocation. +Every `from` is a join. Every `where` is a join predicate. Every `select` is +the model projection at the leaf. The diagnostic position is carried by +whichever step's constraint fails. -**Recommendation:** **A**. The two-check-kinds refactor preserves the -zero-alloc hot path for type-level rules (which will always be the majority) -and cleanly encapsulates the iteration + multi-emit logic in one place. +The DSL becomes: **Shapes are relations. Lenses are joins. Predicates are +filters. Projections are the SELECT. Violations are constraint failures.** -### 3. Squiggle location — where the red underline appears - -Current `SquiggleAt`: `Identifier | Keyword | Attribute | FullDeclaration`. -All resolve relative to the `TypeDeclarationSyntax`. +--- -Member-level rules need an analogous enum resolved against the -`MemberDeclarationSyntax`: +## What Scribe is becoming + +The framing above reframes the whole project. Scribe is **not a generator +toolkit** — a generator toolkit helps you emit source text (Quill, Template, +Naming still do that, and still matter). Scribe's centre of gravity is +shifting to something larger: **a query language for symbol graphs**, with +two consumers. + +- **The analyzer consumer** materialises the query as diagnostics — "report + missing rows and broken predicates." Every failed join, every unsatisfied + predicate, every unique-key violation becomes a squiggle at the correct + span. +- **The generator consumer** materialises the query as source — "for each + row in the result set, project it into a file." The `ShapedSymbol` + stream feeds `RegisterSourceOutput`; the model carries exactly the + information the emitter needs. + +Same query, two projections. The analyzer proves the code's shape is +correct; the generator transmutes that shape into infrastructure. That's +the whole pipeline — from declaration to diagnostic and from declaration to +emitted code — driven by one declarative artefact. + +This is the primitive the framework authoring community has been missing. +Every attribute-driven framework (EF Core, MediatR, MassTransit, ASP.NET +routing, FluentValidation, Hermetic, dozens more) reimplements bits of this +query language by hand, poorly, with no shared vocabulary. Scribe + this DSL +names the pattern and makes it reusable. + +What Scribe ships, stated precisely: + +| Layer | Role | +| ----- | ---- | +| **Quill / Template / Naming / XmlDoc** | Text-emission primitives. Unchanged. | +| **Shape DSL** | Query language over the C# declaration graph. Filters, joins, projections, violations. | +| **`ToAnalyzer()`** | Materialises a Shape as a `DiagnosticAnalyzer`. | +| **`ToProvider(context)`** | Materialises a Shape as an `IncrementalValuesProvider>` for source generation. | +| **Ink** | Fixer infrastructure — the third consumer: "for each diagnostic row, produce a patch." | + +Three consumers, one query. That's the shape of the project. -```csharp -public enum MemberSquiggleAt -{ - Identifier, // property/method/field identifier token - FullDeclaration, // the whole member node - TypeAnnotation, // the return/field type syntax - FirstAttribute, // first attribute list -} -``` +--- -`SquiggleLocator` (core) gets a parallel `MemberSquiggleLocator` that knows how -to extract these from the concrete member syntax kinds -(`PropertyDeclarationSyntax`, `FieldDeclarationSyntax`, -`MethodDeclarationSyntax`, `EventDeclarationSyntax`, ...). +## Foci — first-class navigation targets -The type-level and member-level enums are distinct on purpose — mixing them -hides category errors. +The current DSL has exactly one focus: `INamedTypeSymbol`. Navigation +requires new focus types, each one an equatable wrapper over a symbol plus a +breadcrumb back to its origin (for location reporting and cache stability). -### 4. Member discovery — symbol-based, not syntax-based +| Focus | Wraps | Location source | +| ----- | ----- | --------------- | +| `TypeFocus` | `INamedTypeSymbol` | identifier / keyword / attribute / full decl | +| `AttributeFocus` | `AttributeData` | `ApplicationSyntaxReference` | +| `TypeArgFocus` | `ITypeSymbol` + position in a generic parameter list | the `TypeSyntax` at that position in the attribute / base-list / method signature | +| `ConstructorArgFocus` | `TypedConstant` + index | the argument expression syntax | +| `NamedArgFocus` | `TypedConstant` + name | the `name = value` syntax | +| `BaseTypeChainFocus` | sequence of `INamedTypeSymbol` | the base-list entry for each step | +| `MemberFocus` | `ISymbol` (field/property/method/event) | identifier / full decl / type annotation / first attribute (already implemented by `MemberSquiggleLocator`) | -The check should iterate `INamedTypeSymbol.GetMembers()` rather than the -syntax tree directly. Reasons: +Every focus is equatable, cache-safe, and knows how to resolve its own +squiggle location. The existing `TypeFocus` equivalent today is implicit in +`TypeShape` — it would be extracted as an explicit type. -- Works across `partial` declarations without per-declaration deduplication. -- Gives the match predicate full symbol semantics (`IsStatic`, - `IsImplicitlyDeclared`, `DeclaredAccessibility`, `AttributeData`). -- Matches how the existing type-level predicates already consume symbols. +--- -Locations are derived from `ISymbol.DeclaringSyntaxReferences` → resolve each -reference to a `MemberDeclarationSyntax` at squiggle time. +## Primitives to add + +Ordered by layering dependency. Each row assumes the ones above it. + +1. **`Lens`** — `Func>` plus a + location-propagation function. The foundation. Every navigation below is + an instance. +2. **`Shape Shape.SelectMany(Lens)`** — + the core DSL method. Produces a new Shape rooted at `TTarget`, whose + violations bubble back up through the lens with correct locations. +3. **Built-in lenses** as extension methods, each returning + `Shape`: + - `Attributes(fqn)` — `TypeFocus → AttributeFocus`. With optional + `min`/`max` for presence-count constraints. + - `GenericTypeArg(index)` — `AttributeFocus → TypeArgFocus`. + - `ConstructorArg(index)` — `AttributeFocus → ConstructorArgFocus`. + - `NamedArg(name)` — `AttributeFocus → NamedArgFocus`. + - `BaseTypeChain()` — `TypeFocus → BaseTypeChainFocus`. + - `AsTypeShape()` — `TypeArgFocus → TypeFocus` (re-enter a type shape on + a navigated type). + - `Members(filter?)` — `TypeFocus → MemberFocus` (generalises the existing + `ForEachMember` match). +4. **Leaf predicates on non-type foci**: + - `AttributeFocus.Exists()` (gated via min/max on the lens) + - `TypeArgFocus.MustImplement(fqn)`, `.MustDeriveFrom(fqn)`, + `.MustBeParsable()`, `.MustBeSealed()` — all reusing the type-level + predicate catalogue lifted to apply at a navigated focus. + - `ConstructorArgFocus.MustBe(value)` / `.MustSatisfy(pred)`. + - `MemberFocus.MustHaveAttribute(fqn)` — nested navigation (a member + `SelectMany`'s into its own attributes). +5. **Disjunction** — `Shape.OneOf(shape1, shape2, …)`. Passes when any + alternative passes; reports a fused diagnostic when none do. Needed for + `[KeyPart]`'s "readonly partial record struct OR abstract partial record" + rule. +6. **Quantifiers** — `All(lens, sub)` (every navigated focus must satisfy), + `Any(lens, sub)` (at least one must), `None(lens, sub)` (built atop + `ForEachMember`'s current "must not declare" idiom but generalised to any + lens). Most `.ForEachX` sugar collapses to `All`. +7. **Cross-focus predicates** — `a == b` comparisons lifted to the DSL + (`SymbolEquals`, `SameOriginalDefinition`, …). Needed for the cycle in + Event Contract (the `[Applies]` on the aggregate must reference the + same `evt` we navigated from). +8. **Violation path** — `DiagnosticInfo.FocusPath` — a breadcrumb of lens + hops so richer reporting (`"on type X, attribute [ComposedOf] #2, type argument TPart: does not implement IKeyPart"`) + can be rendered without losing the root diagnostic's message format. +9. **Query-comprehension desugaring** — nothing formal in the language; just + ensure the fluent API's method names match LINQ's `SelectMany` / `Where` / + `Select` so `from / where / select` desugars cleanly. Already true today + for the shipped pieces; needs to be preserved deliberately. + +Items 1–4 are the minimum viable core. Items 5–7 are the features that let +HKey / Event Contract / most frameworks drop back to zero imperative code. +Items 8–9 are quality-of-life on top of a working system. -Implicit / compiler-generated members (record primary constructor parameters, -value-equality backing, synthesized property accessors) must be filtered — -emitting on them would be noise. Filter before handing to the user predicate: -`IsImplicitlyDeclared == false` and syntax reference count > 0. +--- -### 5. Fixer interface — giving the fix access to the member +## Worked examples -`IShapeFix.FixAsync` today: +### HierarchicalKey (fully declarative) ```csharp -Task FixAsync( - Document document, TypeDeclarationSyntax typeDecl, - Diagnostic diagnostic, CancellationToken ct); +public static readonly Shape Shape = + Stencil.ExposeAnyType() + .Implementing(KnownFqns.IHierarchicalKey) + .MustBeRecordStruct() // SCRIBE032 + .MustBePartial() // SCRIBE001 + .MustBeReadOnly() // SCRIBE033 + .Attributes(KnownFqns.ComposedOf, min: 1) // WORD1301 + .GenericTypeArg(0) + .MustImplementOrHaveAttribute( + KnownFqns.IKeyPart, KnownFqns.KeyPart) // WORD1302 + .Etch(…); + +public static readonly Shape PartShape = + Stencil.ExposeAnyType() + .WithAttribute(KnownFqns.KeyPart) + .OneOf( // WORD1303 + s => s.MustBeRecordStruct().MustBeReadOnly().MustBePartial(), + s => s.MustBeAbstract().MustBeRecord().MustBePartial()) + .Attributes(KnownFqns.Discriminator) + .ConstructorArg(0) + .MustBeUniqueWithinAttributeSet() // WORD1306 + .Attributes(KnownFqns.Param) + .ConstructorArg(1) + .MustBeParsable() // WORD1305 + .Etch(…); + +public static readonly Shape DiscShape = + Stencil.ExposeAnyType() + .WithAttribute(KnownFqns.Discriminator) + .BaseTypeChain() + .Any(t => t.HasAttribute(KnownFqns.KeyPart)) // WORD1304 + .Etch(…); ``` -A member-level fix needs the member node. Options: - -**A. Second interface** — `IMemberShapeFix : FixAsync(..., MemberDeclarationSyntax, ...)`. -The dispatcher (`ShapeCodeFixProvider`) picks based on `FixKind` category. - -**B. One interface, diagnostic carries location** — keep `IShapeFix`; the fix -calls `root.FindNode(diagnostic.Location.SourceSpan)` to get the member itself. -This is already how the generic resolver locates the type. +The current analyzer is ~400 lines of imperative walking. The target above is +under thirty. Every `WORD13xx` ID surfaces at the correct span (the +`[ComposedOf<>]` attribute argument for WORD1302, the `[Discriminator("…")]` +argument for WORD1306, the base-list entry for WORD1304). -**C. Split the base** — `IShapeFix` provides the diagnostic + document, and -two narrow helper delegates (`ResolveType` / `ResolveMember`) hang off it as -extension methods. - -**Recommendation:** **B** with a helper. The existing dispatcher already does -`root.FindNode(diagnostic.Location.SourceSpan).AncestorsAndSelf().OfType().FirstOrDefault()`. -A parallel `AncestorsAndSelf().OfType().FirstOrDefault()` -is the member equivalent — no second interface needed. The `IShapeFix` -contract stays one interface; individual fix implementations resolve the node -at whatever granularity they need. - -### 6. Custom-fix escape hatch — for WORD1005 itself - -Even with member-level checks in place, the specific WORD1005 fix -(rewrite instance member → static extension method on a generated -`implicit extension` type) is too bespoke to live as a `FixKind` primitive. - -Two paths: - -**A. `FixKind.Custom` + delegate-backed fix** — Shape DSL allows attaching a -user delegate at declaration time: +### Event Contract (the cycle) ```csharp -.ForEachMember( - match: ..., - report: new MemberDiagnosticSpec(id: "WORD1005", ...), - fix: (document, memberNode, diagnostic, ct) => ConvertToExtensionAsync(...)); +from cmd in Stencil.ExposeAnyType().Implementing(KnownFqns.ICommandLaw) +from raises in cmd.Attributes(KnownFqns.RaisesEvent) +from evt in raises.GenericTypeArg(0).AsTypeShape() + .MustImplement(KnownFqns.IEventLaw) +from applied in evt.Attributes(KnownFqns.AppliedBy) +from agg in applied.GenericTypeArg(0).AsTypeShape() +where agg.Members(m => m is IMethodSymbol) + .Any(m => m.Attributes(KnownFqns.Applies) + .Any(a => a.GenericTypeArg(0).SymbolEquals(evt))) +select new CommandEventContract(cmd.Model, evt.Model, agg.Model); ``` -The delegate serialises into a `CustomFix` registry keyed off the diagnostic -ID. `ShapeCodeFixProvider` looks up the delegate instead of resolving via -`FixResolver`. - -**B. Hand-written sidecar fixer** — Shape emits the analyzer with -`fixKind=None`; a conventional `CodeFixProvider` in the consumer project -handles those IDs. This is the status quo for the existing WORD1005 fixer. +One query. Violations surface at the exact link in the chain that fails: +missing `[RaisesEvent]` on the command, missing `[AppliedBy]` on the +event, missing `[Applies]` method on the aggregate, or a mismatch in the +cycle. -**Recommendation:** **A**, long-term. **B** is acceptable in the interim — -it's what's shipping now, and it doesn't block the member-level analyzer -work. The analyzer-side and the delegate-fix surface can ship in separate -phases. - -### 7. Projection stability — `ShapedSymbol.Violations` - -Today `Violations` is `EquatableArray`. `DiagnosticInfo` -carries `Id, Severity, MessageArgs, Location`. This already supports one -diagnostic per check → N violations per type trivially (the collection grows). - -No schema change needed. The incremental cache's equality over -`EquatableArray` just needs `DiagnosticInfo`'s location + -args to be stable — which they are, since `LocationInfo` already uses -file-path + span integers. +--- -The only subtlety: the ordering of emitted violations must be deterministic -across compiler invocations, or `EquatableArray.Equals` will churn. Iterate -members in source order (or stable-sort by syntax span) before evaluating. +## Hard parts (where to spend the design care) + +1. **Fluent vs query-comprehension parity.** The fluent form reads as nested + `SelectMany`. The query form reads as flat `from / where / select`. Both + should be first-class and produce identical trees. That constrains method + naming — `SelectMany` can't be wrapped under `Navigate` or + `ForEachAttribute` without breaking query comprehension. The DSL's verbs + should be the LINQ verbs, with sugar extensions on top. + +2. **Location propagation.** Today violations carry a single `Location`. + Nested navigations need a stack. Options: (a) a full breadcrumb + (`FocusPath`) stored in `DiagnosticInfo`, rendered into the message only + on materialisation; (b) the innermost focus's location overrides outer + ones, with the breadcrumb only in the message format. (a) is richer but + changes the wire format; (b) is minimal. Lean toward (b) for v1 — the + breadcrumb is redundant with a well-composed `messageFormat`. + +3. **Cache correctness at each hop.** Every lens must produce an + equatable value. `AttributeData` is not equatable out of the box — need + an `AttributeFocus` wrapper with a stable identity (owning symbol Fqn + + attribute FQN + syntax reference span). Same for `TypeArgFocus` and the + constructor-arg foci. Each focus type owns its equality implementation; + tests enforce it. + +4. **Zero-hit semantics.** When a lens returns empty (`.Attributes(X)` on a + type with no such attribute), is that a violation, a silent pass, or + filter-out? Depends on the intent. `.Attributes(fqn, min: 1)` makes the + constraint explicit. Without `min`, the sub-shape applies to zero elements + — trivially true, filter-out. Make `min`/`max` the only way to constrain + presence. + +5. **Disjunction diagnostics.** `OneOf(A, B)` passes when any alternative + passes. What does the failure message say? The fused diagnostic should + list both expectations ("expected readonly partial record struct OR + abstract partial record — was neither"). Needs a message-format + convention baked into the `OneOf` primitive. + +6. **Cross-focus equality.** `a.SymbolEquals(b)` inside a query + comprehension requires both `a` and `b` to be in scope and equatable. + The foci need a `.Symbol` accessor that returns a wrapped-but-equatable + identity for comparison without leaking raw `ISymbol`s (which aren't + cache-safe). + +7. **Generator vs analyzer consumption.** A single Shape tree feeds both: + `ToAnalyzer()` reads only the violations; `ToProvider(context)` emits + `ShapedSymbol` where `Model` is the `select` projection. The + leaf `Project` call must be reachable from every navigation path + — today it lives on `TypeShape` (the type-focus builder). It needs to + lift to every focus type, or (more likely) remain only at the top level, + with navigations feeding the model constructor via capture. --- -## Open questions - -1. **Multi-target member check** — should a single `ForEachMember` ever emit - different IDs based on the member (e.g. "fields are error, properties are - warning")? Or do we require the user to call `ForEachMember` twice with - narrower predicates? The second is cleaner and avoids a per-violation ID - field. - -2. **Attribute-driven exclusion** — should there be first-class support for - `[AllowExtraMember]`-style opt-outs on the offending member? Probably a - helper on top of the match predicate: - `member.GetAttributes().Any(a => a.AttributeClass?.Name == "AllowExtraMemberAttribute")`. - Not a DSL primitive until a second use case appears. - -3. **Fix-all semantics** — the existing `ShapeFixAllProvider` iterates - documents + groups by type. Member-level fixes can emit multiple - diagnostics per type whose fixes may edit overlapping spans (removing - multiple properties from one record). The per-type annotation strategy - still works, but the inner loop must re-locate the *member* after each - edit, not just the type. Needs a thinking pass — probably per-member - annotations, same pattern, one level deeper. - -4. **Is the right abstraction "member" or "declaration"?** Some future rules - may want to iterate over e.g. base-list entries, type parameters, or - constraint clauses. `ForEachMember` is narrow. A more general - `ForEach(selector, match, report)` is tempting but probably - overkill until we have two distinct use sites. +## What's actually missing to ship this ---- +Measured against the shipped code in `Scribe/Shapes/`: -## Phased rollout +| Piece | State | Work | +| ----- | ----- | ---- | +| `Lens` abstraction | Does not exist | New core type. ~100 LOC with docs + tests. | +| `TypeFocus`, `AttributeFocus`, `TypeArgFocus`, `ConstructorArgFocus`, `BaseTypeChainFocus` | Partial — `TypeShape` is an implicit `TypeFocus` | Extract the implicit, add the four new ones. ~400 LOC incl. equality + locators. | +| Built-in lenses (`Attributes`, `GenericTypeArg`, `ConstructorArg`, `NamedArg`, `BaseTypeChain`, `AsTypeShape`) | Do not exist | ~300 LOC + tests. | +| Leaf predicates on non-type foci (`MustImplement`, `MustBeParsable`, `MustBe`, `SymbolEquals`) | Partial — type-level predicates exist but are bound to `TypeShape` | Refactor to be focus-parametric. Mostly mechanical. | +| `Shape.OneOf` | Does not exist | Shape combinator. ~80 LOC + careful diagnostic fusion. | +| `All` / `Any` / `None` quantifiers | `None`-equivalent exists via `ForEachMember`'s "must not declare" pattern | Generalise to all lenses. ~100 LOC. | +| `DiagnosticInfo.FocusPath` (or breadcrumb-in-message) | Does not exist | Decide (2) above first. Probably message-only for v1. | +| Query-comprehension support | Naming is already close (`Project` = `Select`, `ForEachMember` ~= `SelectMany`) | Rename for exact LINQ compat, or add pass-through methods. | +| Fix catalog on navigated foci | N/A for v1 | Deferred. Fixes today operate on type declaration; navigated-focus fixes (e.g. "remove this `[ComposedOf]` attribute") are a Phase 13 concern. | +| Docs + cookbook | Does not exist for this DSL | New page under `docs/`. Replaces this design doc once stable. | -| Phase | Scope | -| ----- | ----- | -| **11a** | `ForEachMember` primitive + `MemberCheck` + `MemberSquiggleAt` + member-resolving dispatch. Enables member-level *diagnostics only*. WORD1005's existing hand-written fixer stays in place. | -| **11b** | `FixKind.Custom` + delegate-backed fix registration. Enables migrating `IdentifierExtraMembersFix` into the Shape surface. | -| **11c** | Sugar primitives on top of `ForEachMember` if a second real use case appears. Otherwise skip. | +Rough total: **~1000 LOC new code**, ~200 LOC refactored, +~400 LOC of tests. Two focused weeks if the API design is locked up front; +four if the API has to be iterated against real consumers. -Phase 11a is independently valuable: Hermetic's analyzer migration can drop -the hand-written `IdentifierWord` entirely, and WORD1005 continues to work -with its existing sidecar fixer during the transition. +The **hard work is the API**, not the engine. The engine is Roslyn's +`IncrementalValuesProvider` plus equatable-value plumbing — both already +working. The engine scales to the design. The design is what decides whether +developers adopt this or fall back to `context.RegisterSyntaxNodeAction`. --- ## Non-goals -- **No** support for cross-type rules ("type A must be referenced by type B"). - That's a relation-level concern, solved via `ShapeBuilder`'s `Relation` - projections and out of scope here. -- **No** support for statement-level or expression-level rules. Those belong - in operation-walking analyzers, not Shape. -- **No** attempt to express the `IdentifierExtraMembersFix` rewrite as a - declarative Shape fix. It's legitimately custom Roslyn work; the DSL's - contribution is the delegate escape hatch, not a new transform grammar. +- **No support for operation-level or expression-level rules.** Those belong + in operation-walking analyzers, not Shape. (Unchanged from prior draft.) +- **No attempt to subsume the existing `Prism.By` stream-join.** That + primitive is correct and complementary — it handles the "two independent + streams joined by string key" shape that navigation can't express cheaply + (cross-assembly, orphan-diagnostic semantics). Navigation (Lens) is for + walking a single symbol's structure; `Prism.By` is for joining two shape + streams. Both coexist. +- **No autogenerated fixers for navigated-focus violations.** Fix delegates + (`FixKind.Custom`) already exist; authoring custom fixes for + attribute-argument or type-arg violations is possible today. A declarative + navigated-focus fix catalog can come later (Phase 13+) once real consumers + demonstrate the patterns worth baking in. diff --git a/docs/dsl.md b/docs/dsl.md new file mode 100644 index 0000000..53cdb36 --- /dev/null +++ b/docs/dsl.md @@ -0,0 +1,265 @@ +# The Shape DSL + +**A query language for the C# declaration graph.** + +**Audience:** anyone authoring an analyzer, source generator, or code fixer with Scribe. If you want term definitions, read the [Glossary](glossary.md). If you want design rationale and planned primitives, read [design-member-level-shapes.md](design-member-level-shapes.md). If you want the text-emission side (Quill, templates, naming), read [writing-generators.md](writing-generators.md). This document is about the *language* — how the pieces compose into a working query. + +--- + +## What the DSL Is + +Scribe's centre of gravity. A declarative language for describing: + +- What shape a piece of source code should have. +- Which parts of it matter. +- What must be true at each part. +- What model to extract at the leaf. + +The query is authored **once**. Three pipelines consume it: + +- **Analyzer** — reports predicate failures as diagnostics. +- **Generator** — emits one output file per surviving row. +- **Ink (fixer)** — rewrites the source for each reported diagnostic. + +Same query, three projections. That is the whole promise. + +--- + +## The Central Insight + +**The DSL is relational algebra over declarations.** Every piece maps onto a SQL/LINQ concept you already know: + +| DSL concept | Relational equivalent | +| --- | --- | +| Shape | Relation (a filtered, projected stream of rows) | +| Focus | The row type at a given stage | +| Lens | Join (one-to-many navigation to a related relation) | +| Predicate | `WHERE` clause | +| Projection | `SELECT` — the terminal model | +| Violation | A constraint failure attached to a row | +| Disjunction (`OneOf`) | `UNION` of predicate trees | +| Quantifier (`All` / `Any` / `None`) | Aggregate predicates over a joined set | + +Every framework-authoring problem that looks like *"given a type with attribute X whose type argument T must satisfy Y"* is one navigation + one predicate in this grammar. What used to be hundreds of lines of imperative Roslyn becomes a few lines of composed Shapes. + +--- + +## The Four Building Blocks + +Every Shape query is built from four kinds of pieces. They compose in the same grammar at every level of nesting. + +### 1. Shape — the query + +A filter-and-projection rooted at a specific focus. The entry point is `Stencil.ExposeAnyType()`, `Stencil.ExposeRecord()`, or a similar constructor. Every chained call produces a new Shape; nothing mutates in place. + +```csharp +Shape s = Stencil.ExposeAnyType().Implementing(KnownFqns.IThing); +``` + +### 2. Focus — the position + +Where the Shape is anchored in the symbol graph. Every Shape has exactly one focus type. Predicates and lenses are **focus-specific** — `MustBePartial` applies to a `TypeFocus`, `MustBeParsable` applies to a `ConstructorArgFocus`. + +Seven focus types, covering every position in the C# symbol graph you can squiggle at: + +`TypeFocus`, `AttributeFocus`, `TypeArgFocus`, `ConstructorArgFocus`, `NamedArgFocus`, `BaseTypeChainFocus`, `MemberFocus`. + +See the [Glossary](glossary.md#focus) for what each one wraps. + +### 3. Lens — the navigation + +A one-to-many projection from one focus to another. `Lens` is a `Func>` plus a location-propagation function that carries the destination's source span back to any violation reported there. + +A lens is a `SelectMany`. You can chain any number of them. A deeply-nested query like *"for every type implementing X, for every `[Foo]` attribute, `T` must implement Y"* is three lens hops. + +### 4. Predicate & Projection — the leaves + +Predicates are tests (`MustBePartial`, `MustImplement(fqn)`, `MustBeParsable`, `SymbolEquals`…). A failing predicate becomes a **violation** with a diagnostic ID, message, and squiggle location. + +Projection is the terminal `.Etch(builder)` that produces the equatable `TModel` attached to the surviving row. The stream of `ShapedSymbol` is what the consumer reads. + +--- + +## Two Forms, One Tree + +The DSL is authored in either of two forms. They produce identical trees; pick whichever reads better for the problem. + +### Fluent form + +Reads like a method chain. Good for linear validations where each step narrows the previous. + +```csharp +Shape thingShape = + Stencil.ExposeAnyType() + .Implementing(KnownFqns.IThing) + .MustBePartial() + .MustBeSealed() + .Attributes(KnownFqns.Widget, min: 1) + .GenericTypeArg(0) + .MustImplement(KnownFqns.IWidget) + .Etch(t => new ThingModel(t.Fqn)); +``` + +### Query-comprehension form + +Reads like LINQ. Good for multi-join queries where several foci need to be in scope at the same time (for cross-focus equality, or for building a model from multiple navigated positions). + +```csharp +Shape contract = + from thing in Stencil.ExposeAnyType().Implementing(KnownFqns.IThing) + from widget in thing.Attributes(KnownFqns.Widget) + from w in widget.GenericTypeArg(0).AsTypeShape() + .MustImplement(KnownFqns.IWidget) + from handler in w.Attributes(KnownFqns.HandledBy) + from h in handler.GenericTypeArg(0).AsTypeShape() + where h.Members(m => m is IMethodSymbol) + .Any(m => m.Attributes(KnownFqns.Handles) + .Any(a => a.GenericTypeArg(0).SymbolEquals(w))) + select new ThingWidgetContract(thing.Model, w.Model, h.Model); +``` + +Every `from` is a lens. Every `where` is a predicate. Every `select` is a projection. The DSL's verbs are the LINQ verbs exactly — `SelectMany`, `Where`, `Select` — so this desugars without wrappers. + +--- + +## Composition Rules + +A small set of rules governs how the pieces fit together. + +### Rule 1 — Navigation changes focus. + +A lens applied to a `Shape` produces a `Shape`. All subsequent predicates and lenses are interpreted at the new focus. The `AsTypeShape()` lens is the re-entry point that lifts a `TypeArgFocus` back to a `TypeFocus`, enabling type-level predicates on a navigated type argument. + +### Rule 2 — Presence constraints live on the lens. + +Lenses that can return zero or more targets take optional `min` / `max` parameters: + +```csharp +.Attributes(KnownFqns.Widget, min: 1, max: 3) +``` + +Without a `min`, a zero-hit lens silently passes (sub-Shape applies to zero rows — trivially true). With `min: 1`, an empty result becomes a violation. This is the *only* way to constrain presence — never bake it into predicates. + +### Rule 3 — Disjunction wraps alternative Shapes. + +`Shape.OneOf(A, B, …)` passes if any alternative passes. When all fail, it reports one fused diagnostic that lists every unsatisfied expectation. Use this when a declaration may legitimately take one of several forms. + +```csharp +Shape s = Stencil.ExposeAnyType() + .WithAttribute(KnownFqns.Foo) + .OneOf( + x => x.MustBeRecordStruct().MustBeReadOnly(), + x => x.MustBeAbstract().MustBeRecord()); +``` + +### Rule 4 — Quantifiers express intent over lens output. + +`All(lens, sub)` is the default and is usually implicit in a chain. `Any(lens, sub)` and `None(lens, sub)` make "at least one" and "none of" explicit. Reach for them when *which* sub-focus matches matters, or when you're testing for the absence of something. + +### Rule 5 — Cross-focus predicates need two foci in scope. + +Comparisons between two navigated positions (e.g. *"the `[Handles]` type argument on the handler must be the same event we came from"*) require the query-comprehension form so both foci are captured as named variables, then a `SymbolEquals(a, b)` predicate in the `where` clause. + +### Rule 6 — The terminal is always a projection. + +Every Shape ends with `.Etch(…)`. Without a projection, the Shape has no leaf — no equatable output for the incremental pipeline to cache. `TModel` must be equatable (prefer records). Incremental caching depends on this. + +--- + +## Materialisation — Three Consumers + +A single Shape is handed to whichever consumer you need. Each consumer reads different parts of the same tree. + +### `ToAnalyzer()` + +Produces a `DiagnosticAnalyzer`. Reads every `ShapedSymbol.Violations` and reports each as a diagnostic at the focus's squiggle location. The projection is unused. + +### `ToProvider(context)` + +Produces an `IncrementalValuesProvider>` for use inside an `IIncrementalGenerator`. Typically filters to violation-free rows, then feeds `RegisterSourceOutput`. The violations are unused. + +### Ink + +Produces a `CodeFixProvider`. For each diagnostic the analyzer reports, Ink applies a fix — either a built-in `FixKind` (twenty-one catalogued kinds: `MakePartial`, `AddAttribute`, `RemoveModifier`, etc.) or a `FixKind.Custom` paired with a registered rewrite delegate. + +### The three are independent. + +You can ship any subset. An analyzer without fixers, a generator without an analyzer, or all three from one Shape. The query is authored once either way. + +--- + +## What a Full Query Looks Like + +Here is the shape of a representative end-to-end analyzer + generator, written against a generic `IThing` / `[Widget]` domain. + +```csharp +public static class ThingShape +{ + public static readonly Shape Shape = + from thing in Stencil.ExposeAnyType() + .Implementing(KnownFqns.IThing) + .MustBePartial() // SCRIBE001 + .MustBeSealed() // SCRIBE002 + from widget in thing.Attributes(KnownFqns.Widget, min: 1) // WIDGET101 + from arg in widget.GenericTypeArg(0).AsTypeShape() + .MustImplement(KnownFqns.IWidget) // WIDGET102 + from ctor in widget.ConstructorArg(0) + .MustSatisfy(s => !string.IsNullOrEmpty(s)) // WIDGET103 + select new ThingModel(thing.Fqn, arg.Fqn, ctor.Value); +} + +// Analyzer +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThingAnalyzer : DiagnosticAnalyzer +{ + public override void Initialize(AnalysisContext ctx) => ThingShape.Shape.ToAnalyzer().Register(ctx); + // Supported descriptors derived from the Shape +} + +// Generator +[Generator] +public sealed class ThingGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext ctx) + { + IncrementalValuesProvider> things = ThingShape.Shape.ToProvider(ctx); + ctx.RegisterSourceOutput(things, (spc, thing) => + { + Quill quill = Quill.Begin($"global::{thing.Model.Fqn}Dispatcher"); + // …emit code using thing.Model… + spc.AddSource(thing.Model.Fqn + ".g.cs", quill.Inscribe()); + }); + } +} +``` + +One Shape, one model, two consumers. Every diagnostic surfaces at the right span (the `[Widget<>]` argument for WIDGET102, the constructor argument for WIDGET103). The generator emits code only for rows whose Shape passed. + +--- + +## Why It Works + +Three properties make the DSL composable at any depth. + +1. **Every focus is equatable.** Roslyn symbols are not stable across compilations; foci wrap them with identity derived from fully-qualified names plus syntax spans. The incremental pipeline can cache on focus equality. +2. **Every lens carries a location-propagation function.** Violations reported at a deeply-nested focus squiggle at the right source span without the query author having to pass locations manually. +3. **Every Shape is pure and immutable.** No hidden state, no order dependency between chained calls. Shapes can be composed, stored in `static readonly` fields, and shared across analyzers/generators. + +The engine underneath is Roslyn's `IncrementalValuesProvider` plus equatable-value plumbing. The engine is already stable; the DSL is the surface that lets you author against it declaratively. + +--- + +## Non-Goals + +- **Operation-level and expression-level rules.** Shape is a language for *declaration* shape, not for validating the body of a method. Those belong in operation-walking analyzers. +- **Subsuming Prism under Lens.** Lens (structural) and Prism (keyed) are complementary, not redundant. A Lens walks a single symbol's structure along edges Roslyn already provides. A Prism joins two independent Shape streams by computed key — used for cross-assembly orphan diagnostics and cross-type composition (union bases ↔ variants, commands ↔ events) where no structural edge exists. Both coexist as first-class navigation primitives. See [Glossary: Prism](glossary.md#prism). +- **Hiding Roslyn.** Shape is a thin declarative layer over Roslyn, not a replacement. A `.Etch(t => …)` builder has full access to the underlying `INamedTypeSymbol` when it needs to read something the DSL hasn't exposed yet. + +--- + +## Related Documents + +- [Glossary](glossary.md) — Term-by-term definitions of every concept used above. +- [design-member-level-shapes.md](design-member-level-shapes.md) — Design rationale, planned primitives, worked HierarchicalKey and Event Contract examples. +- [writing-generators.md](writing-generators.md) — The Transform → Register → Render pattern and Quill usage once you have the projected models. +- [quill-reference.md](quill-reference.md) — Quill API reference for the text-emission half of a generator. diff --git a/docs/gap-analysis.md b/docs/gap-analysis.md new file mode 100644 index 0000000..960cea7 --- /dev/null +++ b/docs/gap-analysis.md @@ -0,0 +1,720 @@ +# Scribe — Gap Analysis + +**Measuring the current implementation against the six-word vocabulary (Shape, Focus, Lens, Prism, Projection, Materialisation) and the full Shape DSL vision.** + +**Audience:** anyone doing the work, or deciding what to do next. Pair this with the [glossary](glossary.md) (the vocabulary) and [dsl.md](dsl.md) (the authored surface) and [design-member-level-shapes.md](design-member-level-shapes.md) (the architectural rationale). + +--- + +## Scope + +Two independent gap analyses: + +- **Part A — Refactoring gaps.** Changes needed to align the *existing* code and docs to the new vocabulary. Mostly renames, extractions, and clarifications. No new capability. Keeps the surface coherent so the feature work can land on a clean foundation. +- **Part B — Feature gaps.** Capabilities missing today that the vision requires. Navigation, disjunction, quantifiers, cross-focus predicates, diagnostic breadcrumbs. The meat. + +Build order matters. Part A unblocks Part B — trying to add `Lens` before extracting `TypeFocus` would bake the implicit-focus assumption into the new abstraction. + +--- + +## Design Principle — Escape Hatches as First-Class Citizens + +Some constraints won't fit the general DSL cleanly. That's not a failure mode — it's expected. Any declarative surface that covers every shape of query the framework-authoring world can dream up would be unusable. What matters is **how the escape hatch fits in**. + +The rule: **every custom/bespoke path must be a natural entry point into the DSL, not a bolted-on appendage.** The author should feel they're reaching for the same fluent surface — just at a position where the built-in vocabulary runs out. + +Scribe already has one exemplar of this done well, and one implicit one: + +- **`FixKind.Custom` + `Ink.WithCustomFix(tag, delegate)`.** Twenty built-in fix kinds plus one slot for bespoke rewrites, dispatched via the *same* enum, keyed by a stable tag, registered through the same Ink surface. A custom fix doesn't look or feel different from a built-in fix at the call site — the escape hatch is flush with the wall. +- **`Project(builder)`.** The terminal projection is an arbitrary user lambda, but it sits at the natural end of every Shape chain. Nobody thinks of it as an escape hatch even though it is one — which is the point. + +New primitives added in Parts A and B should follow the same discipline. Every DSL built-in ships with (or explicitly justifies the absence of) a custom counterpart at the same fluent position. Three places where this is already load-bearing: + +- **V.5 set aggregations.** Built-ins are primary: `Unique()`, `UniqueBy(key)`, `CardinalityAtMost(n)`, `CardinalityExactly(n)` cover the common cases and must exist as first-class verbs. Nested escape hatch: `.SatisfyingSet(customPredicate, diagnosticSpec)` for the long tail — takes a `Func, IEnumerable>`, lives at the same fluent position, uses the same diagnostic-dispatch path. +- **Leaf predicates on any focus.** Built-ins primary: the `MustBeX` catalogue per focus (`MustBePartial`, `MustImplement`, `MustBeParsable`, `MustNotBeEmpty`, …). Nested escape hatch: `.Satisfy(customPredicate, diagnosticSpec)` alongside them, so authors can bolt on custom checks without dropping to imperative Roslyn. One verb family, two depths of customisation. +- **V.4 `MustSatisfy(shape)` as a composition seam — not an escape hatch.** `MustSatisfy` is a built-in primitive, same as `MustBePartial`. The fact that the embedded Shape can contain arbitrary author logic doesn't make `MustSatisfy` an escape hatch — it makes it a seam, like `Project(lambda)` or `OneOf(A, B, …)`. Seams accept user code at a well-defined position; escape hatches bypass the vocabulary. They look similar but they're different things. + +**What this forbids.** A separate `ScribeBespoke` namespace, a `RawRoslyn()` escape from the fluent chain, a set of standalone helpers living outside the Shape surface. If someone reaches for a custom predicate and has to leave the DSL to write it, we've failed. + +**Where this lives in the plan.** Not a separate work item — a reviewer's checklist. Every primitive added in Part A or Part B must ship with (or explicitly justify the absence of) a custom counterpart that feels native. Document the escape hatch in the [glossary](glossary.md) and [dsl.md](dsl.md) next to the built-ins. + +--- + +## Part A — Refactoring Gaps + +Six tasks. Ordered by how much they unblock downstream work. + +### A.1 Rename `Relation.Pair` → `Prism` + +**Status (April 2026). ✅ Shipped.** `Scribe/Shapes/Prism.cs`, `Scribe/Shapes/ShapedPrism.cs` are the current surface. `Relation` / `PairBuilder` / `ShapedPair` no longer exist in the tree. No call sites in downstream consumers reference the old names. + +### A.2 Extract `TypeFocus` as an explicit type + +**Why.** Today `TypeShape` *is* the type focus — the focus is implicit in the builder. Every other focus type (Attribute, TypeArg, ConstructorArg, NamedArg, BaseTypeChain) will be a first-class equatable wrapper. If `TypeFocus` stays implicit, the new foci will live in a parallel universe and the focus-parametric predicate plan falls apart. + +**What exists.** `TypeShape` in `Scribe/Shapes/TypeShape.cs` — accumulates predicates, carries the `INamedTypeSymbol`, projects to `Shape` via `Project(...)`. + +**Target shape.** + +- New `TypeFocus` record/struct wrapping `INamedTypeSymbol` + origin breadcrumb (syntax span for cache stability). +- `TypeShape` becomes `Shape` — the fluent builder parameterised by focus. +- Every predicate method moves to an extension on `Shape` (see A.3). +- `Project(...)` remains terminal, unchanged on the surface, but internally takes the focus as input. + +**Effort.** ~2 days. Care needed to keep existing authored Shapes source-compatible (same method names, same chain shape). + +**Status (April 2026).** ⏳ **Deferred** per maintainer direction. A minimal-invasion +retrofit has shipped: `ShapeCheck` delegates now take `TypeFocus`, and `TypeShape`'s +central `AddCheck` / `Check(...)` wrap `INamedTypeSymbol` lambdas into +`TypeFocus` lambdas at the boundary so every existing `MustBe*` predicate continues +to authorise unchanged. Full extraction (moving 40+ `MustBe*` members to extensions +on `Shape`) remains on the backlog; the retrofit makes it a pure move +with zero callsite blast radius when the decision is made. + +### A.3 Make the predicate catalogue focus-parametric + +**Why.** Today every `MustBeX` lives on `TypeShape`. When `AttributeFocus` and friends arrive (Part B), they need their own predicate catalogues (`MustHaveConstructorArg`, `MustBeParsable`, …). The discipline is: **predicates are focus-specific**, enforced by the type system. + +**What exists.** Fifteen-plus predicates on `TypeShape`: `MustBePartial`, `MustBeSealed`, `MustBeAbstract`, `MustBeStatic`, `MustBeNamed`, `MustBeGeneric`, `MustBePublic/Internal/Private`, `Implementing`, `MustHaveAttribute`, `MustExtend`, `MustBeInNamespace`, negations. + +**Target shape.** + +- Move all type-level predicates to extensions on `Shape` (or a `TypeFocusPredicates` static class used as extensions). +- The predicate implementation (`Func`) becomes `Func`. +- Namespace the static predicate class so IntelliSense on `Shape` *can't* offer `MustBePartial`. Compiler-enforced focus-specificity. + +**Effort.** ~1 day after A.2. Mostly mechanical — each predicate is a thin wrapper. + +### A.4 Rename / generalise `ForEachMember` to `Members` lens + +**Why.** `ForEachMember(match, spec)` is the existing member navigation — but it bundles navigation, predicate, and diagnostic spec into one imperative call. In the new world, `Members(filter?)` is a **Lens** (`TypeFocus → MemberFocus`), and any predicate that follows runs at the new focus with its own diagnostic. + +**What exists.** `TypeShape.ForEachMember(Func match, MemberDiagnosticSpec)` — evaluates the match, reports the diagnostic per member. Paired with `MemberCheck`, `MemberDiagnosticSpec`, `MemberSquiggleAt`, `MemberSquiggleLocator`. + +**Target shape.** + +- Add `Members(filter?)` as a lens on `Shape` returning `Shape`. +- Keep `MemberFocus` wrapping `ISymbol` + `MemberSquiggleLocator` for span resolution. +- `MemberDiagnosticSpec` becomes redundant in the lens form — diagnostics come from predicates on the member focus, not a baked-in spec. +- Leave `ForEachMember` as a deprecated alias *only* if real consumers depend on it today (check Hermetic.Logos). Otherwise remove. + +**Effort.** ~2 days (includes wiring at least one `MemberFocus` predicate so the replacement has teeth). + +**Status (April 2026).** ⏳ **Deferred pending design call.** `Members(...)` lens and +`FocusShape` leaf predicates (B.4) are already live and functionally +subsume `ForEachMember`. The hold-up is whether `FocusCheck` should grow +squiggle-target / auto-fix metadata to fully replace the `MemberSquiggleAt` / +`MemberSquiggleLocator` channel that `ForEachMember` uses today — or whether member-level +fixes continue to route through a separate path. Not blocking downstream consumers. + +### A.5 Update `docs/dsl.md` to match the new vocabulary + +**Status (April 2026). ✅ Shipped.** `dsl.md` was scrubbed during the Prism rename; the remaining `Relation` mentions are SQL relational-algebra terminology (the glossary analogy), not the removed `Relation.Pair` primitive. Non-Goals already references Prism directly. + +--- + +## Part B — Feature Gaps + +Nine capabilities missing today. Ordered by dependency. B.1–B.4 are the minimum viable core. B.5–B.7 are the features that let real consumers (HKey, Event Contract, EF/MediatR/ASP.NET routing analyzers) drop to zero imperative code. B.8–B.9 are polish. + +### B.1 `Lens` abstraction + +**What.** A `Func>` plus a location-propagation function. The single foundation every navigation below is an instance of. + +**Surface.** + +```csharp +public sealed record Lens( + Func> Navigate, + Func LocateTarget); +``` + +**Depends on.** A.2 (TypeFocus extracted) — so the first consumer can be `Shape.Attributes(fqn)`. + +**Effort.** ~1 day. Small type; the care is in the equality semantics of the outputs it produces. + +### B.2 New Focus types + +Each is an equatable wrapper over a Roslyn symbol plus a breadcrumb for cache stability and diagnostic span resolution. + +| Focus | Wraps | Span source | +| --- | --- | --- | +| `AttributeFocus` | `AttributeData` + owning symbol Fqn | `ApplicationSyntaxReference` | +| `TypeArgFocus` | `ITypeSymbol` + index | `TypeSyntax` at the generic position | +| `ConstructorArgFocus` | `TypedConstant` + index | argument expression syntax | +| `NamedArgFocus` | `TypedConstant` + name | `name = value` syntax | +| `BaseTypeChainFocus` | sequence of `INamedTypeSymbol` | base-list entry per step | +| `MemberFocus` | `ISymbol` (field/property/method/event) | already covered by `MemberSquiggleLocator` | + +**Depends on.** A.2 (TypeFocus model to copy). + +**Effort.** ~3 days. Equality + location resolvers + unit tests for each. `MemberFocus` is mostly already built (see A.4). + +### B.3 Built-in lenses + +Each is an extension method on a focus, returning a `Shape`. + +| Lens | From | To | Presence params | +| --- | --- | --- | --- | +| `Attributes(fqn)` | `TypeFocus` / `MemberFocus` | `AttributeFocus` | `min`, `max` | +| `GenericTypeArg(index)` | `AttributeFocus` | `TypeArgFocus` | — | +| `ConstructorArg(index)` | `AttributeFocus` | `ConstructorArgFocus` | — | +| `NamedArg(name)` | `AttributeFocus` | `NamedArgFocus` | — | +| `BaseTypeChain()` | `TypeFocus` | `BaseTypeChainFocus` | — | +| `AsTypeShape()` | `TypeArgFocus` | `TypeFocus` | — | +| `Members(filter?)` | `TypeFocus` | `MemberFocus` | `min`, `max` (new) | + +**Depends on.** B.1 (Lens), B.2 (Focus types). + +**Effort.** ~3 days. Seven lenses × ~50 LOC + tests. Presence-constraint semantics (`min`/`max`) need tight test coverage. + +### B.4 Focus-specific leaf predicates + +Once B.3 lands, every navigated focus needs its own predicate catalogue. + +| Focus | Predicates | v1 status | +| --- | --- | --- | +| `AttributeFocus` | (Existence is gated via `min`/`max` on the lens; no predicates needed at v1) | ✅ — lens-level | +| `TypeArgFocus` | `MustImplement(fqn)`, `MustDeriveFrom(fqn)`, `MustBeParsable()`, re-use via `AsTypeShape()` | ✅ `MustImplement`, `MustDeriveFrom`, `AsTypeShape`; ⏳ `MustBeParsable` deferred | +| `ConstructorArgFocus` | `MustBe(value)`, `MustSatisfy(pred)`, `MustNotBeEmpty()` | ✅ `MustBe`, `MustNotBeEmpty`; `Satisfy(pred, id, title, …)` available via the generic escape hatch | +| `NamedArgFocus` | same as `ConstructorArgFocus` | ✅ same status | +| `BaseTypeChainFocus` | quantifier-only (see B.6) | ⏳ B.6 | +| `MemberFocus` | `MustHaveAttribute(fqn)`, `MustBePublic`, `MustBeReadOnly`, `MustBeStatic`, … | ✅ `MustHaveAttribute`, `MustBePublic`, `MustBeStatic`, `MustBeReadOnly`; broader catalog (`MustBeVirtual`, `MustReturnTask`, accessibility matchers) deferred | + +**Depends on.** A.3 (predicates made focus-parametric), B.2 (foci exist). + +**Effort.** ~3 days. Each predicate is small; the volume adds up. + +**v1 scope landed (April 2026).** Representative subset of each focus's catalog plus a generic +`Satisfy` escape hatch (two overloads: simple predicate, and +`Compilation`+`CancellationToken`) living on every `FocusShape` via +[`FocusShapeEscapeHatches`](../Scribe/Shapes/FocusShapeEscapeHatches.cs). Diagnostic IDs +reserved: `SCRIBE060`–`SCRIBE069` member-level, `SCRIBE070`–`SCRIBE079` type-arg-level, +`SCRIBE080`–`SCRIBE084` constructor-arg-level, `SCRIBE085`–`SCRIBE089` named-arg-level. + +**Deferred — architectural, not sugar.** + +- Auto-fix metadata on `FocusCheck` (`Target` / `Fix`) — violations on nested + foci currently land on the parent lens's smudge anchor. The code-fix layer is + root-only in v1; lift when a consumer demonstrates a lens-level fix that can't be + reformulated as a root fix. + +Everything else (typed `MustSatisfy`, `MustBeParsable`, expanded `MemberFocus` catalog) is sugar over the generic `Satisfy` escape hatch and is removed from the backlog. Ship on demand if a real consumer shows up. + +### B.5 `Shape.OneOf` disjunction + +**Status (April 2026). ✅ Shipped** (v1 subset). + +**What.** Two entry points into a disjunction carrier (`OneOfTypeShape`) that +composes two or more fully-authored `TypeShape` alternatives: + +- **`Stencil.OneOf(params TypeShape[] alternatives)`** — reads as an authoring + verb; best for new disjunctions written inline. +- **`TypeShape.OneOf(params TypeShape[] others)`** — instance form; best when + you already have one alternative in hand and want to compose siblings. + +Both produce the same `OneOfTypeShape`, which exposes only `.Etch(...)` — +the disjunction is terminal: it cannot accumulate further lenses since those +would be ambiguous across branches. + +```csharp +var readonlyStruct = Stencil.ExposeRecordStruct() + .MustBeReadOnly() + .MustBePartial(); + +var abstractRecord = Stencil.ExposeRecord() + .MustBeAbstract() + .MustBePartial(); + +var shape = Stencil.OneOf(readonlyStruct, abstractRecord) + .Etch(ctx => new KeyPartModel(ctx.Fqn)); +``` + +**Semantics.** For each symbol whose kind matches ≥1 alternative (and whose +primary attribute/interface matches, if declared): + +1. Evaluate each alternative's checks + lens branches into a private scratch. +2. If any scratch is empty → overall silent. +3. Otherwise emit a single fused diagnostic at the symbol's origin listing the + unsatisfied check IDs per branch, separated by `—OR—`. + +**Reserved IDs (shipped).** + +| Purpose | ID | +| ------------------------------------ | ---------- | +| Fused "no alternative passed" | SCRIBE100 | +| Branch kind-mismatch marker | SCRIBE101 | + +Authors override the fusion diagnostic via `fusionSpec: new DiagnosticSpec(...)`. + +**v1 restrictions.** Enforced at authoring time with `ArgumentException`: + +- All alternatives must share the same primary attribute driver (or none). +- All alternatives must share the same primary interface driver (or none). +- Alternatives cannot declare member checks (`MemberChecks` collection must be + empty). Members declared via a `Members(...)` lens on an alternative are fine — + those live inside `LensBranches` and run through the normal branch evaluator. + +**Deferred.** Formatted branch messages in the fusion output (currently lists +IDs only — requires an Id→MessageFormat lookup harvested from every +alternative's check tree); per-alternative member checks; `FocusShape`- +level `OneOf` for same-focus disjunction. + +### B.6 `All` / `Any` / `None` quantifiers + +**Status (April 2026). ✅ Shipped** (v1 subset). + +**What.** Higher-order predicates applied across a lens's output set. Surfaces as a +`Quantifier quantifier = Quantifier.All` parameter on each lens entry point rather +than a trailing `.Any(...)` / `.None(...)` method, so the aggregation intent sits +next to the lens it governs. + +```csharp +// All (default) — per-child violation at each offender's smudge anchor. +typeFocus.Members(kind: SymbolKind.Property, configure: m => m.MustBePublic()); + +// Any — silent if at least one child passes; one aggregate diagnostic at the +// parent's origin otherwise. +typeFocus.Members( + kind: SymbolKind.Property, + configure: m => m.MustBePublic(), + quantifier: Quantifier.Any); + +// None — silent if every child fails; per-child aggregate at each offender +// whenever one passes. +typeFocus.Members( + kind: SymbolKind.Method, + configure: m => m.MustHaveAttribute("System.ObsoleteAttribute"), + quantifier: Quantifier.None); +``` + +`All` is the default; `Any` / `None` require a paired `quantifierSpec` override +(or fall back to the reserved SCRIBE09x IDs below). A child "passes" iff every +nested leaf predicate holds **and** every nested sub-branch produces zero +violations — sub-branch diagnostics are folded into the pass/fail decision and +suppressed from the outer diagnostic stream. + +**Reserved IDs (shipped).** + +| Lens | `Any` aggregate | `None` aggregate | +| -------------- | --------------- | ---------------- | +| `Members` | SCRIBE090 | SCRIBE091 | +| `Attributes` | SCRIBE092 | SCRIBE093 | +| `BaseTypeChain`| SCRIBE094 | SCRIBE095 | + +Authors override via `quantifierSpec: new DiagnosticSpec(Id, Title, Message, Severity)`. + +**Depends on.** B.1, B.2, B.3. + +**Deferred.** Fluent `.Any(...)` / `.None(...)` trailing-form sugar (would wrap the +existing parameter surface). Only worth surfacing if the param form proves +awkward in practice. + +### B.7 Cross-focus predicates + +**Status (April 2026). ✅ Shipped** (v1 — static helpers + focus extension methods). The public `FocusSymbols` class in `Scribe.Shapes` now exposes cross-focus comparison helpers that callers wire into `Satisfy` predicates or lens-configure callbacks: + +| API | Compares | +| --- | --- | +| `FocusSymbols.SymbolEquals(ISymbol?, ISymbol?)` | Strict `SymbolEqualityComparer.Default` — closed-generic `List` vs. `List` are unequal. | +| `FocusSymbols.SameOriginalDefinition(ISymbol?, ISymbol?)` | Via `ISymbol.OriginalDefinition` — open/closed-generic instantiations of the same definition compare equal. | +| `this TypeFocus.SameOriginalDefinition(TypeFocus / TypeArgFocus)` | Cross-focus wrapper over the underlying symbols. | +| `this TypeArgFocus.SameOriginalDefinition(TypeFocus / TypeArgFocus)` | Mirror of the above. | + +Both helpers return `false` when either side is null. Symbol references flow only through in-flight analyzer / generator passes — never into cached pipeline state — so this is cache-safe. + +**Canonical use.** Event Contract cycle check: capture the outer `TypeFocus` via closure, then assert a navigated `TypeArgFocus` refers to the same underlying type. + +```csharp +TypeFocus? outer = null; +Stencil.ExposeClass() + .Attributes("AppliedByAttribute", configure: attr => + attr.GenericTypeArg(0, configure: arg => + arg.Satisfy( + predicate: (TypeArgFocus focus) => outer is { } o && o.SameOriginalDefinition(focus), + id: "EVT001", title: "...", message: "..."))) + .Etch(...) +``` + +**Deferred.** Full query-comprehension `SelectMany` multi-from form (tracked with B.9's deferred item). For v1, cross-focus assertions are expressed via closure capture inside nested lens-configure callbacks, which matches the Event Contract use case without requiring additional desugaring infrastructure. + +### B.8 `DiagnosticInfo.FocusPath` + +**Status (April 2026). ✅ Shipped** (v1 — option 1: structured `FocusPath` on `DiagnosticInfo`, rendered at materialisation time). + +**What shipped.** + +- `DiagnosticInfo` gained a 5th positional field `string? FocusPath = null` — cache-safe, included in equality, backward-compatible default. +- Every built-in lens entry (`Attributes(fqn)`, `Members(kind)`, `BaseTypeChain`) now carries a human-readable `HopDescription` that is composed onto the parent path with a `→` separator and stamped on every `DiagnosticInfo` the branch produces. +- At fire time, `DiagnosticInfo.Materialize` prefixes the rendered message with `"[path] "` when `FocusPath` is non-null. A shallow wrapped descriptor is built per diagnostic — accepted because firing is rare relative to equality checks. + +**Examples.** + +| Source | Rendered prefix | +| --- | --- | +| Top-level `MustBePartial()` violation | *(no prefix)* | +| `Attributes("System.ObsoleteAttribute", min: 1)` presence breach | `[Attributes("System.ObsoleteAttribute")] ...` | +| `Attributes(X).ConstructorArg(0, min: 1)` presence breach | `[Attributes("X")] ...` (sub-lens hop appended when its own hop description is set) | + +**Deferred.** Per-sub-lens hop descriptions (`ConstructorArg(0)`, `GenericTypeArg(0)`, `NamedArg("Foo")`) — left null in v1, so their violations still inherit the parent lens's path. Adding those is mechanical; defer until a concrete use case asks for the extra granularity. + +### B.9 LINQ query-comprehension parity + +**What.** Ensure the fluent API's method names match LINQ's `SelectMany` / `Where` / `Select` so `from / where / select` desugars cleanly. + +**Status (April 2026). ✅ Shipped** (single-focus comprehension). `TypeShape` now exposes LINQ pass-throughs: + +| Method | Desugars from | Delegates to | +| --- | --- | --- | +| `Select(Func)` | `select` clause | `Etch` — wraps the focus-shaped selector into an `EtchDelegate`. | +| `Where(Func)` | `where` clause | `Check(...)` with a default `SCRIBE200` diagnostic id — the single-arg overload needed for comprehension desugaring. | +| `Where(Func, DiagnosticSpec)` | — (fluent only) | `Check(...)` — use when you want a stable custom id. | + +Both forms are now equivalent: + +```csharp +// Query-comprehension form +Shape q = + from t in Stencil.ExposeClass().MustBePartial() + where t.Fqn.Length > 0 + select new Collected(t.Fqn); + +// Fluent form +Shape f = Stencil.ExposeClass() + .MustBePartial() + .Where(t => t.Fqn.Length > 0) + .Select(t => new Collected(t.Fqn)); +``` + +**Deferred.** Multi-`from` (`SelectMany`) that joins two focus streams into a multi-variable comprehension (as drafted in [dsl.md](dsl.md#query-comprehension-form)). For v1, multi-focus composition is expressed via the lens-entry callbacks (`Attributes(...)`, `Members(...)`, `BaseTypeChain(...)`) which already accept a nested `FocusShape` configure callback. + +**Reserved IDs.** `SCRIBE200` — default diagnostic id for single-arg `Where(predicate)` when no explicit spec is supplied. + +--- + +### B.10 Conditional Shapes — `When(cond, sub)` + +**What.** Apply a sub-Shape only when a compilation-wide condition holds. Gated predicates/lenses that switch on/off based on environmental facts (referenced assemblies, available types, target framework, etc.). + +**Worked example — Marten-gated constraint.** + +```csharp +Stencil.ExposeClass() + .MustBePublic() + .When(Env.References("Marten"), + s => s.Check( + t => SingleGettablePublicProperty(t), + spec: DiagnosticSpec.Error("MARTEN001", + "Type must expose exactly one gettable public property when Marten is referenced."))) + .Etch(t => new ThingModel(t.Fqn)); +``` + +When Marten is referenced → the `Check` fires. When Marten is absent → the sub-Shape is skipped entirely; the type passes with only `MustBePublic`. + +**Condition catalogue (proposed).** + +| `Env.References(assemblyName)` | Compilation references the named assembly. | +| `Env.HasType(fqn)` | Compilation can resolve a type by fully-qualified name. | +| `Env.HasSymbol()` | Compilation can resolve a .NET type. | +| `Env.Target(tfm)` | Target framework matches. | +| `Env.Custom(Func)` | Escape hatch. | + +**Cache correctness.** Compilation-wide facts must flow as `IncrementalValueProvider` (singular, not `Values`) so each fact is computed once per compilation and combined with per-declaration providers via `.Combine(...)`. The authoring API hides this — `Env.References(...)` returns an opaque `EnvCondition` the pipeline composes correctly. + +**Relation to existing primitives.** `OneOf` gives *unguarded alternatives*; `When` gives a *guarded branch*. Complementary, not overlapping. + +**Metaphor fit.** The lithography "process option" — the same mask with conditional DRC rules depending on the target fab process. The rules aren't different masks; they're conditional applications of rules to one mask. + +**Effort.** ~3–5 days: the `Env` condition type + `IncrementalValueProvider` plumbing + `.When(...)` on each focused Shape + tests + glossary entry. + +**Status (April 2026). ⏳ Deferred** per maintainer direction. Post-v1 work. + +--- + +## Suggested Sequence + +Phased for parallelism where possible. Each phase leaves the build green. + +### Phase 1 — Vocabulary alignment (Part A, 1 week) + +- A.1 `Relation.Pair` → `Prism` +- A.5 `dsl.md` scrub +- A.2 Extract `TypeFocus` +- A.3 Make predicates focus-parametric +- A.6 `ToFixProvider` → `ToInk` (if time) + +*Ship gate:* all existing consumers (Hermetic.Logos) pass tests. No behavioural change. Glossary / dsl / code all speak the same six words. + +### Phase 2 — Navigation core (B.1–B.4, 2 weeks) + +- B.1 `Lens` +- B.2 New focus types (start with AttributeFocus + TypeArgFocus; others can land incrementally) +- B.3 Built-in lenses (Attributes, GenericTypeArg, AsTypeShape first; then the rest) +- B.4 Focus-specific leaf predicates +- A.4 `ForEachMember` → `Members` lens (lands naturally here once B.1 exists) + +*Ship gate:* the HKey worked example from [design-member-level-shapes.md](design-member-level-shapes.md) compiles and runs end-to-end. That example alone validates ~70% of Phase 2. + +### Phase 3 — Composition primitives (B.5–B.7, 1 week) + +- B.5 `Shape.OneOf` +- B.6 Quantifiers (`All` / `Any` / `None`) +- B.7 Cross-focus predicates + +*Ship gate:* the Event Contract worked example compiles and runs — the cycle (`Command → Event → Aggregate → Event`) is the acid test for cross-focus equality + quantifiers. + +### Phase 4 — Polish (B.8–B.9, 0.5 week) + +- B.8 `FocusPath` (message-only variant) +- B.9 LINQ query-comprehension parity verified against worked examples + +*Ship gate:* both fluent and query-comprehension forms of the Event Contract example produce identical trees; error messages render the focus breadcrumb. + +**Total rough cost:** 4.5 weeks of focused work. Phases 2 and 3 are the ones that determine whether Scribe becomes the primitive the framework authoring community adopts. Phase 1 is cheap but unblocks everything. + +--- + +## Validation — HierarchicalKey (the full stress test) + +The analysis above is the abstract plan. Here it is stress-tested against the hardest real consumer we need to ship: Hermetic's **HierarchicalKey (HKey) family**. HKey is a small zoo of interlocking declarations — composed keys, leaf parts, union bases, union variants, discriminators, parametrised constructors — all validated today by ~400 lines of imperative Roslyn. If we can express it as a handful of related Shapes with every diagnostic auto-squiggling at the right span, the DSL has earned its keep. Every assumption in Parts A and B is on the hook. + +The union-keys sub-case below is the keystone. It is also the canonical example of how Scribe lets you define a compile-time-safe discriminated union *today* — one that can drop-in-replace when native .NET 11 unions land, because the information captured at design time is a superset of what the native feature needs. + +### The pattern + +A union key is two structurally distinct declarations working together: + +- **Base** — `abstract partial record MyUnion : IHierarchicalKey`. One per union. Carries the union's identity. +- **Variants** — `readonly partial record struct Alpha(...) : MyUnion`, `readonly partial record struct Beta(...) : MyUnion`, … N per union. Each variant has its own discriminator and parameter list. + +The **final shape** we need to emit and validate is `Shape` where `UnionModel` carries the base's metadata together with the ordered list of its variants. **One row per union — not per variant.** + +### Two navigations, two primitives + +Expressing this requires navigation in *both* directions, and they are **not the same kind of navigation**. + +| Direction | Mechanism | Primitive | +| --- | --- | --- | +| Variant → Base | `BaseTypeChain().AsTypeShape()` — walk the base-type list to the declared parent. Structural: the edge is in the symbol graph. | **Lens** | +| Base → Variants | Scan all types in the compilation, match by "this type's base-chain contains our Fqn." No graph edge exists from `INamedTypeSymbol` to "my derived types." | **Prism** | + +That this single example needs *both* confirms the vocabulary split: Lens (structural) and Prism (keyed) are genuinely different operations. One primitive wouldn't cover it. + +### Mapping to the DSL + +The authored surface — assuming Parts A and B have shipped — looks something like: + +```csharp +// Shape 1 — the base of any union. +public static readonly Shape BaseShape = + Stencil.ExposeAnyType() + .Implementing(KnownFqns.IHierarchicalKey) + .MustBeAbstract() + .MustBeRecord() + .MustBePartial() + .WithAttribute(KnownFqns.UnionBase) + .Etch(t => new UnionBaseModel(t.Fqn)); + +// Shape 2 — one variant of some union. +public static readonly Shape VariantShape = + Stencil.ExposeAnyType() + .MustBeRecordStruct() + .MustBeReadOnly() + .MustBePartial() + .BaseTypeChain() + .Any(t => t.AsTypeShape().Implementing(KnownFqns.IHierarchicalKey)) + .Attributes(KnownFqns.Discriminator, min: 1, max: 1) + .ConstructorArg(0) + .MustNotBeEmpty() + .Etch(v => new UnionVariantModel(v.Fqn, v.BaseFqn, v.Discriminator, v.Params)); + +// Composed — the union as a whole. +public static readonly Shape UnionShape = + Prism.By( + left: BaseShape.Select(b => (key: b.Fqn, b)), + right: VariantShape.Select(v => (key: v.BaseFqn, v)), + mode: PrismMode.RequireLeftHasRight) + .Aggregate((b, variants) => new UnionModel(b, variants.ToImmutableArray())); +``` + +Three things are happening: + +1. **Two independent Shapes** validate the base and the variants in isolation. Each ships useful diagnostics on its own. +2. A **Prism** joins them by Fqn ↔ BaseFqn. This is cross-type composition Roslyn does not provide structurally. +3. The Prism produces a **single composed `Shape`** that downstream consumers (analyzer, generator, fixer) treat exactly like any other Shape. + +### Gaps this case exposes + +Six design-space gaps the current Part B plan understates. V.1–V.3 fall out of the union pattern specifically. V.4–V.6 fall out of HKey's broader type hierarchy — composed keys referencing parts, discriminators with cross-variant uniqueness, location routing through embedded Shapes. + +#### Gap V.1 — Prism needs a fan-in terminal projection + +**What.** Today's `Relation.Pair` (future Prism) emits pair-rows: one row per matched left/right combination. A base with three variants produces three pair-rows. The union case needs **fan-in**: one `UnionModel` per base with its variants collected. + +**Target surface.** `PrismBuilder.Aggregate(Func, TModel>)` — groups right by key, applies the aggregator once per left. + +**Why it matters.** Without fan-in, the author has to collect the pair-stream, group downstream, and re-wrap into an equatable `UnionModel` themselves. Incremental caching gets shaky across the manual grouping. It also reads badly — the DSL should express "union = base + its variants" in one line. + +**Effort.** ~1 day on top of the A.1 rename. Equality on the grouped output needs care (`ImmutableArray` with deterministic ordering). + +**Where this slots in Part B.** Promote this to **B.10 — Prism fan-in projection**, depends on A.1. + +#### Gap V.2 — Prism should produce a composable `Shape`, not just a materialisation-side artefact + +**What.** Today the `PairBuilder` has `.Matched` (a stream) and `.Diagnostics` — consumers feed these into `RegisterSourceOutput` or the analyzer context themselves. There is no `Shape` coming out the other side. For the union case we want the `UnionShape` above to be a first-class Shape, so it can in turn be the left or right input to *another* Prism, or feed into `.ToAnalyzer()` / `.ToProvider(ctx)` / Ink like any other Shape. + +**Target.** `Prism.By(...).Aggregate(...)` returns `Shape`. That Shape carries violations (orphan-left under `RequireLeftHasRight`, orphan-right under `WarnOnRightUnused`) plus the projected model. All three Materialisation consumers work on it without special-casing Prism output. + +**Effort.** ~2 days. The violation-plumbing has to thread from the Prism machinery into the shared `ShapedSymbol.Violations` array. Design decision: what is the "root" Focus of a Prism-produced Shape? Proposal: the left row's Focus, since the union is "owned by" the base. Materialising at the left span gives sensible diagnostics. + +**Where this slots in Part B.** Promote this to **B.11 — Prism as Shape combinator**, depends on V.1. + +#### Gap V.3 — `AsTypeShape` needs a `BaseTypeChainFocus` entry point + +**What.** Part B.3 lists `AsTypeShape()` as a `TypeArgFocus → TypeFocus` lens. The union case needs the same thing from `BaseTypeChainFocus` elements — "take a step in the base chain and re-enter a type-level shape at that step." Same operation, different source. + +**Target.** Either (a) make `AsTypeShape()` polymorphic across any focus that wraps an `INamedTypeSymbol`, or (b) ship separate `AsTypeShape()` extensions on each such focus type. Go with (a) — one method, one mental model. + +**Effort.** Trivial (~1 hour) once B.2 foci exist. Worth calling out only so it doesn't get missed. + +**Where this slots in Part B.** Fold into B.3 as a clarification. + +#### Gap V.4 — Shape-as-predicate (sub-Shape references) + +**What.** The HKey composed-key analyzer needs to say *"the generic type argument of `[ComposedOf]` must itself be a valid KeyPart"* — which means re-applying `PartShape` at the navigated type. Without a primitive for this, `PartShape`'s logic gets re-inlined (or worse, duplicated) at every call site that wants to validate a target-is-a-part. + +The user-facing promise — *"a couple of related shapes"* — only holds if Shapes compose by **reference**, not by copy-paste. + +**Target surface.** + +```csharp +typeFocus.Attributes(KnownFqns.ComposedOf, min: 1) + .GenericTypeArg(0) + .AsTypeShape() + .MustSatisfy(PartShape); // embed one Shape inside another as a predicate +``` + +`MustSatisfy(Shape sub)` runs `sub` at the current focus. If `sub` fails, its violations bubble up — re-routed to the *caller's* span by default (see V.6). The sub-Shape's projection is ignored (this is a predicate use, not a composition use; for composition, use the Prism from V.1/V.2). + +**Why it matters.** Without V.4, a type that should satisfy three separate Shapes (e.g. `KeyShape` + `PartShape` + `DiscShape`) can only be expressed by inlining all three predicate trees into one giant monolithic Shape. With V.4, each Shape is authored once, tested in isolation, and composed at call sites. + +**Depends on.** A.2 (TypeFocus), B.1 (Lens), B.2 (foci), ideally V.6 (location re-routing). + +**Effort.** ~2 days. The mechanics are straightforward (run sub-Shape, merge violations). The care is in the violation-routing semantics — see V.6. + +**Where this slots in Part B.** New **B.12 — Shape-as-predicate (`MustSatisfy`)**, lands in Phase 3. + +#### Gap V.5 — Aggregate constraints (the GROUP BY / HAVING leg of the algebra) + +**What.** A whole family of constraints that assert a **collective property over a set of elements** — not per-element. In relational-algebra terms this is `GROUP BY` + `HAVING`: the third primitive leg alongside per-row filters (Predicates) and row-joins (Lenses/Prisms). Neither a per-focus predicate (*"this one must X"*) nor a quantifier (*"every / at least one / none must X"*) covers it. Quantifiers iterate per-element; aggregates consume the whole set. + +The family is general. Canonical cases: + +| Sub-family | Constraint | Example | +| --- | --- | --- | +| **Distinctness** | No two elements share a value | Discriminator strings unique among union variants | +| **Derived distinctness** | No two elements share a projected key | Parameter names unique within a part | +| **Cardinality** | Set size is `=N`, `≤N`, `≥N` | Exactly one `[Discriminator]` per union base | +| **Coverage** | Every element of some reference set is represented | Every declared event has at least one handler | +| **Ordering** | Elements satisfy an order property | `[Param]` indexes are sequential from 0 | +| **Agreement** | All elements share a common value | All variants of a union live in the same namespace | + +Every framework-authoring codebase reimplements at least half of these by hand. The DSL today has no primitive for any of them. The design doc's fictional `MustBeUniqueWithinAttributeSet` is one case of the family; it deserves a general home. + +**Target surface.** Aggregates attach to a lens's output set, same fluent position as Quantifiers (`All` / `Any` / `None`) but operating on the collection rather than per-element: + +```csharp +typeFocus.Attributes(KnownFqns.Discriminator) + .ConstructorArg(0) + .Unique(); // distinctness + +typeFocus.Attributes(KnownFqns.Param) + .UniqueBy(a => a.ConstructorArg(0)); // derived distinctness + +typeFocus.Attributes(KnownFqns.Primary) + .CardinalityAtMost(1); // cardinality + +typeFocus.BaseTypeChain() + .AgreeOn(t => t.Namespace); // all elements share a value + +UnionBaseShape.Select(b => b.Fqn) + .CoveredBy(UnionVariantShape.Select(v => v.BaseFqn)); // coverage across streams +``` + +**Why it matters.** This isn't a niche pattern — it's one of the three general constraint families the DSL has to carry if it wants to claim "relational algebra over declarations." Without aggregates, *every* consumer ends up writing the same five-to-ten-line group-by-then-scan code for uniqueness, cardinality, and coverage. That imperative sprawl is exactly what we're eliminating. + +**Design care required.** + +- **Diagnostic fusion.** When three variants share a discriminator, who gets squiggled? Proposal: squiggle *every duplicate after the first*, referencing the first in the message (*"discriminator 'X' already used by `Alpha` at line 42"*). Mirrors how the C# compiler reports duplicate type members. +- **Per-group vs whole-set.** Uniqueness within a single type's attributes (per-group) vs uniqueness across all types in the compilation (whole-set) are different operations. Both are needed; the surface has to make the scope explicit. Proposal: aggregates default to per-group scoped by the enclosing Shape; use `.Across(stream)` or similar to widen scope when needed. +- **Escape hatch.** Built-ins cover the canonical cases above. Long-tail aggregates get `.SatisfyingSet(customPredicate, diagnosticSpec)` at the same fluent position — same diagnostic-dispatch path, same verb family. + +**Depends on.** B.3 (lenses), B.4 (leaf predicates on the right foci). + +**Effort.** ~4 days (up from 3 now that the family is properly scoped). Six-ish concrete primitives (`Unique`, `UniqueBy`, `CardinalityAtMost`, `CardinalityExactly`, `AgreeOn`, `CoveredBy`) plus the `SatisfyingSet` escape hatch, each with its own diagnostic-fusion rules. + +**Glossary follow-up.** Aggregate deserves a Supporting Vocabulary entry in [glossary.md](glossary.md) alongside Quantifier. Add when V.5 lands, with the sub-family table above. + +**Where this slots in Part B.** New **B.13 — Aggregate constraints**, lands in Phase 3 alongside V.4. + +#### Gap V.6 — Location routing through embedded Shapes + +**What.** V.4 raises a question the current plan doesn't address: when `typeFocus.GenericTypeArg(0).AsTypeShape().MustSatisfy(PartShape)` fails because `PartShape` says *"this type must be a readonly partial record struct"* — **where does the squiggle go?** + +Two possible semantics: + +| Mode | Squiggle lands at | When to use | +| --- | --- | --- | +| **At-target** | The failing part's declaration (`readonly partial record struct Foo { … }`) | The target is authored by the same hand — the user can fix it there. | +| **At-call-site** | The `[ComposedOf]` generic argument that brought us here | The target is authored by someone else (another assembly, library code, a type you didn't write) — the user's only actionable fix is the reference. | + +Both are useful. The right default is **at-call-site**, because that's where the user's own source lives for attribute-argument navigations. At-target is the override for cases where the navigation and declaration are co-owned. + +**Target surface.** `MustSatisfy(sub, LocationMode mode = LocationMode.AtCallSite)`. + +**Why it matters.** This *is* the "diagnostics automatically appear in the right places" promise. If V.4 ships without V.6, every embedded Shape fires its diagnostics at the target's declaration, and authors lose the auto-routing magic precisely at the most composable layer of the DSL. + +**Depends on.** V.4, and the location-propagation function already in Lens (B.1). + +**Effort.** ~1 day on top of V.4. The mechanism exists in Lens; V.6 just wires it into `MustSatisfy`. + +**Where this slots in Part B.** Fold into **B.12** (part of the same primitive). + +### What the HKey case confirms + +Beyond the gaps, the walkthrough confirms four design choices are right: + +1. **Two navigation primitives, not one.** Lens (structural) and Prism (keyed) are complementary and both necessary. The union case uses both in a single composition. +2. **Prism belongs in the Shape DSL, not off to the side.** The current placement as a post-materialisation combinator was correct for cross-assembly orphan checks but too narrow. Elevating it to a Shape combinator unifies the surface. +3. **Aggregating projection is the common case.** Most real uses of Prism are "one unit composed of N parts" (union + variants, command + handlers, event + subscribers). Fan-in is the default; raw pair-streams are the escape hatch for power users. +4. **Shapes compose by reference, not by inlining.** The six-word vocabulary names Shape as self-composing, and `MustSatisfy(otherShape)` is the primitive that actually delivers on that. Without it, "a couple of related shapes" collapses back into one giant monolithic Shape. + +### Plan deltas + +Adding to Part B: + +- **B.10 — Prism fan-in projection (`Aggregate(...)`)** — ~1 day, depends on A.1. +- **B.11 — Prism as Shape combinator (returns `Shape`)** — ~2 days, depends on B.10. +- **B.12 — Shape-as-predicate (`MustSatisfy`) with configurable location routing** — ~3 days, depends on B.1 + B.2. Includes V.6 (location mode). +- **B.13 — Set-level aggregations (`Unique`, `UniqueBy`, `CardinalityAtMost`, `CardinalityExactly`)** — ~3 days, depends on B.3 + B.4. +- B.3 — note that `AsTypeShape()` applies uniformly to any focus wrapping an `INamedTypeSymbol`, not just `TypeArgFocus`. + +Sequence implication: B.10–B.13 all belong in **Phase 3** (composition primitives), landing alongside `OneOf` and quantifiers. They share the same design concern (multi-source Shape composition + cross-element assertions) and the same ship gate: the full HKey analyzer — with KeyShape, PartShape, UnionBaseShape, UnionVariantShape, and DiscShape each authored independently and composed via `MustSatisfy`, Prism, and set predicates — compiles and runs, and every diagnostic lands at the right span without the author doing any location bookkeeping. + +**Revised total cost:** ~6 weeks (up from 4.5). Phase 3 grows from 1 week to ~2.5 weeks to absorb B.10–B.13. + +### The .NET 11 unions angle + +The `UnionModel` produced above captures strictly more information than native .NET 11 discriminated unions will expose: not just the variant type list, but every variant's discriminator string, parameter list, and the base's own attribute metadata. When .NET 11 unions ship, the *generator* that consumes `UnionShape` can switch its emit strategy from "hand-rolled visitor + pattern-match helpers" to "native union syntax" without touching the Shape. + +The Shape is the contract; the generator is the implementation. That's the point of keeping the declaration-query stage pure — it makes the emission side freely replaceable. + +--- + +## Out of Scope + +Explicitly not in this gap analysis: + +- **Operation / expression-level rules.** The DSL describes declaration shape, not method body behaviour. Belongs in a separate operation-walking layer if ever. +- **Navigated-focus fix catalogue.** `FixKind.Custom` is enough for v1 bespoke fixes on attribute-argument / type-arg violations. A built-in catalogue (e.g. `RemoveAttribute` targeting a navigated `AttributeFocus`) is a later concern. +- **Cross-compilation / MSBuild integration changes.** The engine is stable; no plumbing changes required for Parts A and B. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..9bf3b28 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,397 @@ +# Scribe — Glossary + +**Canonical definitions of the Shape DSL vocabulary and the text-emission primitives.** + +**Audience:** anyone reading or writing Scribe code, Scribe documentation, or a generator/analyzer/fixer built on top of Scribe. If you see a term in a Scribe doc that you're unsure about, this is the dictionary. + +**Scope:** purely Scribe vocabulary. No Hermetic or application terms. Examples use generic domains (`Thing`, `Widget`, `Order`). + +--- + +## The Core Vocabulary + +The entire Shape DSL rests on a small set of concepts drawn from a coherent metaphor family: **photolithography and optics**. A Stencil is the mask you start with. A Shape is that mask focused onto a specific position in the source code. Lenses and Prisms refocus attention. Smudges on the lens carry the breadcrumb that tells a diagnostic where to squiggle. Every other term in this glossary is either a specialisation of one of these or a supporting primitive. + +| Term | Stage | Role | +| --- | --- | --- | +| **Stencil** | Door | Static entry point. Exposes the initial focused Shape (`Stencil.ExposeClass()`, `Stencil.ExposeAnyType()`, ...). Before a Shape exists, the Stencil is the mask. | +| **Shape** | Pattern | What must be true at a Focus. Self-composing. Concrete focused classes: `TypeShape`, `AttributeShape`, `TypeArgShape`, `MemberShape`, ... | +| **Focus** | Pattern / Navigation | Where the Shape is grounded — a position in the symbol graph. Each focused Shape pairs with a Focus row type (`TypeShape` ↔ `TypeFocus`). | +| **Lens** | Navigation | Structural refocus — follows an edge the source already provides. | +| **Prism** | Navigation | Keyed refocus — combines two streams by matching computed keys. | +| **Smudge** | Navigation | The breadcrumb a Lens or Prism carries so a Violation squiggles at the right span after the focus has moved. | +| **Etch** | Seal | Terminal model extraction. Seals the chain into `Shape`. Invoked as `.Etch(builder)`. | +| **Materialisation** | Output | Transforms a sealed `Shape` into analyzer, provider, or fixer. | + +Five stages, eight concepts. Each stage is **conceptually distinct** and maps to its own type in code — no single type tries to represent the whole pipeline. + +**The chipmaking metaphor, end to end.** A Stencil is exposed through UV onto a wafer (Expose); the latent pattern develops as predicates and lens hops accumulate (Develop — implicit in the authoring chain); the final commitment permanently transfers the pattern into the substrate (Etch); the finished die is then packaged into products (Materialisation: analyzer / provider / ink). + +**The lifecycle in one diagram:** + +```text +Stencil.ExposeAnyType() ← EXPOSE: latent pattern on resist + → TypeShape ← focused Shape; predicates accumulate + .MustBePartial() + .Attributes(Widget) ← Lens moves focus (smudge carries the span) + → AttributeShape ← new focused Shape + .GenericTypeArg(0) + → TypeArgShape + .AsTypeShape() + → TypeShape ← Lens re-entry at a new type focus + .MustImplement(IWidget) + .Etch(...) ← ETCH: permanent commitment + → Shape ← sealed query; materialisable + .ToProvider(ctx) ← MATERIALISE + .ToAnalyzer() + .ToInk(...) +``` + +**Three code-type families:** + +| Family | Examples | Phase | +| --- | --- | --- | +| **Stencil** | `Stencil` (static) | Door — not yet a Shape. | +| **Shape** | `TypeShape`, `AttributeShape`, `TypeArgShape`, `MemberShape`, … | Authoring — focused Shape, predicates accumulating. | +| **Shape** | `Shape` | Sealed — projection applied, ready for consumers. | + +A `Shape` is not a Shape at focus `TModel` — `TModel` is the etched model, not a focus. "Shape" is an umbrella name; the type system discriminates whether you hold an authoring-phase focused Shape or a sealed etched Shape. + +--- + +## The Chipmaking Metaphor — In Full + +The DSL's named concepts (Stencil, Expose, Shape, Focus, Lens, Prism, Smudge, Etch, Materialisation) only pick up the **active verbs and nouns** of photolithography. The rest of the chipmaking process has clean analogues too. None of these appear in the code — they're purely mental scaffolding for reasoning about the pipeline. + +| Chipmaking | Scribe equivalent | Code name? | +| --- | --- | --- | +| **Wafer** — raw silicon substrate the fab works on | The Roslyn `Compilation`. The stream of declarations every Shape is scanning. | No — it's "the Compilation" in code. | +| **Photomask / Stencil** — the pattern template | `Stencil` — the static door. | ✅ `Stencil` | +| **Exposure** — UV projects mask onto photoresist | `Stencil.Expose*()` — creates the initial focused Shape. | ✅ `ExposeClass`, `ExposeAnyType`, … | +| **Photoresist** — reactive layer that records the pattern | The focused Shape's accumulating predicates, lens hops, and prism joins during authoring. The latent image of the pattern. | No — it's just "the authoring chain." | +| **Development** — chemicals reveal the latent image | Implicit in the fluent chain. Each `MustBeX` / `Lens` call sharpens the image that's already on the photoresist. | No — there is no `Develop` call. | +| **Etch** — pattern is permanently transferred into substrate | `.Etch(...)` — seals the chain into `Shape`. | ✅ `Etch` | +| **Die** — one individual chip's worth of pattern on the wafer | `ShapedSymbol` — one matched declaration, carrying its model, violations, and source location. One die per surviving row. | ✅ `ShapedSymbol` | +| **Dicing** — cutting the wafer into individual dies | Implicit in the `IncrementalValuesProvider>` — Roslyn yields one row at a time. | No — handled by Roslyn's incremental pipeline. | +| **Packaging** — fitting a die into DIP / QFN / BGA | `ToAnalyzer()` / `ToProvider(ctx)` / `ToInk(...)`. Same die, three packages. | ✅ Materialisation. | +| **Chip / IC** — the finished, shipped product | The analyzer / generator / fixer DLL that the Scribe consumer actually references. Scribe vanishes into the thing it produces. | No — it's just your analyzer. | +| **Fab** — the photolithography facility itself | Roslyn. Scribe hands design rules to a fab that already exists. | No — Roslyn is Roslyn. | +| **Design Rule Check (DRC)** — pre-fab static verification | What `ToAnalyzer()` does, at the level of the Shape DSL. | No — it's called `Analyzer`. | + +**The full arc, in one sentence.** The Compilation is a wafer flowing through Roslyn's fab; the Stencil exposes a latent pattern into the photoresist of the authoring chain; accumulated predicates develop the image; `.Etch(...)` commits each match as a die (`ShapedSymbol`); `ToAnalyzer` / `ToProvider` / `ToInk` package each die into the chip — analyzer, generator, or fixer — that the consumer drops into their project. + +**Why this matters.** Scribe's naming is deliberately sparse — we don't need a `Develop()` method, because development is already what fluent authoring *is*. The metaphor exists in full in the user's head; the code surfaces only the moments where a name earns its weight. + +--- + +## Stencil + +The door. A static class whose factory methods expose the initial focused Shape. Before a Shape exists, the Stencil is the mask — an unfocused template of "I'm going to match *something* of this general kind." + +Factory methods all begin with `Expose`, echoing the lithography step where UV light projects the mask pattern onto the photoresist and the latent image first appears on the wafer. + +```csharp +TypeShape anyType = Stencil.ExposeAnyType(); +TypeShape classOnly = Stencil.ExposeClass(); +TypeShape records = Stencil.ExposeRecord(); +TypeShape structs = Stencil.ExposeStruct(); +TypeShape ifaces = Stencil.ExposeInterface(); +``` + +The object returned by `Expose*` is a focused Shape. From that moment on, every predicate sharpens specificity and every Lens moves focus to a new position. The Stencil itself is never mutated — it's a stateless door. + +--- + +## Shape + +The pattern — what must be true at a Focus. A Shape is a specification: "a type that implements `IThing`, is partial, is sealed, and has at least one `[Widget<>]` attribute." + +Shape is **self-composing.** A Shape can contain sub-Shapes reached through Lenses or Prisms, and each sub-Shape is verified at its own Focus. There is no separate "query" concept — a composition of Shapes is itself a Shape. Every example in the docs, no matter how deeply nested, is a single Shape. + +```csharp +Shape shape = Stencil.ExposeAnyType() + .Implementing(KnownFqns.IThing) + .MustBePartial() + .MustBeSealed() + .Attributes(KnownFqns.Widget, min: 1) + .GenericTypeArg(0) + .MustImplement(KnownFqns.IWidget) + .Etch(t => new ThingModel(t.Fqn)); +``` + +**Two code-level forms.** The focused authoring forms are concrete classes named `Shape` (`TypeShape`, `AttributeShape`, `TypeArgShape`, `MemberShape`, ...). Each carries the predicates accumulated so far and the lens/prism chain that led there. Once `.Etch(...)` is called, the chain seals into `Shape` — the terminal, equatable, materialisable form. Authoring and sealed forms are distinct types; the compiler prevents calling predicates on a sealed Shape. + +--- + +## Focus + +Where the Shape is grounded. A Focus is a **Shape focused at a specific position** in the C# symbol graph — exactly the sense of "focus" one uses when speaking of where attention rests. + +Every Focus is an equatable wrapper over a Roslyn symbol plus a breadcrumb to its origin (for cache stability and correct diagnostic squiggle location). Roslyn's raw symbols are not stable across compilations; Focus types derive identity from fully-qualified names plus syntax spans so the incremental pipeline can cache on Focus equality. + +Seven Focus types cover every position in the graph a diagnostic can squiggle at: + +| Focus | Wraps | Where it points | +| --- | --- | --- | +| `TypeFocus` | `INamedTypeSymbol` | A type declaration. | +| `AttributeFocus` | `AttributeData` | An attribute usage on a type, member, or parameter. | +| `TypeArgFocus` | `ITypeSymbol` + index | A single type argument inside a generic attribute, base-list entry, or method signature. | +| `ConstructorArgFocus` | `TypedConstant` + index | A positional argument passed to an attribute's constructor. | +| `NamedArgFocus` | `TypedConstant` + name | A named argument on an attribute (`[Foo(Bar = 42)]`). | +| `BaseTypeChainFocus` | sequence of `INamedTypeSymbol` | The inheritance chain of a type, step by step. | +| `MemberFocus` | `ISymbol` (field/property/method/event) | A member inside a type declaration. | + +Predicates are **focus-specific** — `MustBePartial` applies to a `TypeFocus`; `MustBeParsable` applies to a `ConstructorArgFocus`. The compiler enforces this: you can't call a predicate at a Focus where it doesn't apply. + +--- + +## Lens + +Structural refocus. A Lens redirects attention from one Focus to another along an edge the symbol graph already provides — attributes, type arguments, constructor arguments, members, base types. Pre-ground optics: Roslyn gives you these paths, the Lens exposes them declaratively. + +Written `Lens`. Internally: a `Func>` plus a location-propagation function that carries the destination's source span back to any violation reported there. + +A Lens is a `SelectMany`. Lenses chain to reach deep positions; each refocuses the view. A query like *"for every type implementing X, for every `[Foo]` attribute, `T` must implement Y"* is three Lens hops. + +Built-in lenses: + +| Lens | From | To | What it does | +| --- | --- | --- | --- | +| `Attributes(fqn)` | `TypeFocus` / `MemberFocus` | `AttributeFocus` | All attributes of the given FQN applied to the focus. Optional `min` / `max` for presence-count constraints. | +| `GenericTypeArg(index)` | `AttributeFocus` | `TypeArgFocus` | The type argument at the given generic position. | +| `ConstructorArg(index)` | `AttributeFocus` | `ConstructorArgFocus` | The positional argument at the given index. | +| `NamedArg(name)` | `AttributeFocus` | `NamedArgFocus` | The named argument with the given name. | +| `BaseTypeChain()` | `TypeFocus` | `BaseTypeChainFocus` | The full inheritance chain, ordered from the type up to `object`. | +| `AsTypeShape()` | `TypeArgFocus` | `TypeFocus` | Re-enter a type-level Shape on a navigated type. Enables predicates like `MustImplement` on a generic argument. | +| `Members(filter?)` | `TypeFocus` | `MemberFocus` | All members matching an optional filter. | + +**Presence constraints live on the Lens.** `min` and `max` on a Lens express "I expect at least / at most N of these." Without them, a zero-hit Lens silently passes (sub-Shape applies to zero rows — trivially true). This is the only way to constrain presence. + +--- + +## Prism + +Keyed refocus. A Prism combines two independent Shape streams by matching on a computed key. Custom-cut optics for cross-stream joins where no structural edge exists. + +In physics, a prism combines or separates light by wavelength. In the DSL, a Prism combines or separates streams by key. Same metaphor family as Lens — both are optical elements of the instrument — but where a Lens follows a pre-existing path, a Prism **matches on a property you grind into it**. + +```csharp +// Every left row must have a matching right row; orphans become errors. +Prism.By( + left: widgets.Select(w => (key: w.EventFqn, w)), + right: handlers.Select(h => (key: h.HandlesFqn, h)), + mode: PrismMode.RequireLeftHasRight); +``` + +Prism modes: + +| Mode | Semantic | +| --- | --- | +| `RequireLeftHasRight` | Every left row must have at least one matching right row. Orphans on the left become errors. | +| `WarnOnRightUnused` | Right rows without a matching left row raise warnings. | + +**When to reach for Prism instead of Lens.** A Lens works when the target is reachable from the source via the symbol graph's existing structure. A Prism works when two streams are genuinely independent and you need to relate them by a key you compute — cross-assembly orphan checks, string-keyed registrations, synthesised identity matches. + +--- + +## Smudge + +The breadcrumb a Lens or Prism carries so a Violation surfaces at the right source span after the focus has moved. + +When a Lens hops from an `AttributeFocus` into a `TypeArgFocus`, the destination's syntax span has to travel with it — otherwise a `MustImplement` failure three hops deep would squiggle on the root type instead of the attribute's type argument. Each Lens and Prism carries a small location-propagation function; together the chain of these functions forms a trail of smudges back to the starting Focus. + +A smudge is always a `LocationInfo?` — cache-stable, equatable, and scoped to one hop. Most authors never touch one directly; Ink and the analyzer pipeline consume them when building the final `Diagnostic`. + +**Etymology.** The squiggle the compiler draws on the reader's code is, in a sense, the compiler's own smudge — a mark that says *look here, something is off*. In Scribe, every lens and prism hop leaves a smudge of its own so the final squiggle lands on the right character. + +--- + +## Etch + +Terminal commitment. Written `.Etch(builder)`. Takes the current Focus and produces an equatable `TModel`, sealing the authoring-form Shape into `Shape`. The stream of `ShapedSymbol` values is what every Materialisation consumer reads. + +Etch is the **`SELECT`** of the relational-algebra framing: after all the patterns and navigations, this is what each surviving row looks like. It is also the lithography step where the developed pattern is permanently transferred into the substrate — after etching, no more predicates or lenses can be applied; the die is committed. + +`TModel` **must be equatable.** Prefer records; the incremental pipeline uses equality to skip downstream work when nothing has changed. If `TModel` is not equatable, every compilation re-runs every generator stage. + +--- + +## Materialisation + +Transforms a composed Shape into one of three output pipelines. Each consumer reads a different part of the same Shape; the Shape itself is authored once. + +| Transform | Produces | Reads | Typical use | +| --- | --- | --- | --- | +| `ToAnalyzer()` | `DiagnosticAnalyzer` | `ShapedSymbol.Violations` | *"Prove the code has the expected shape."* | +| `ToProvider(context)` | `IncrementalValuesProvider>` | Clean `ShapedSymbol` rows | *"For each surviving row, emit code."* | +| Ink | `CodeFixProvider` | Diagnostics from the analyzer | *"For each diagnostic, produce a patch."* | + +The three are independent. Ship any subset. A Shape can back an analyzer alone, a generator alone, all three, or any combination. + +--- + +## Supporting Vocabulary + +These primitives live underneath the core vocabulary. They appear in the DSL's surface but are specialisations or extensions of the main concepts. + +### ShapedSymbol + +The equatable unit of output from a Shape. Written `ShapedSymbol`. Carries: + +| Field | What it is | +| --- | --- | +| `Fqn` | The fully-qualified name of the root symbol. Cache key. | +| `Model` | The projected model (type `TModel`). | +| `Location` | Source span for the root. Used for diagnostics. | +| `Violations` | Any predicate failures accumulated during evaluation. | + +Every `ShapedSymbol` is immutable, equatable, and safe to flow through `IncrementalValuesProvider`. + +### Predicate + +A test applied at a Focus. Pass = silent. Fail = **Violation**. + +Predicates are focus-specific. Type-level predicate catalogue (shipped): `MustBePartial`, `MustBeSealed`, `MustBeRecord`, `MustBeReadOnly`, `MustBeRecordStruct`, `MustNotBeNested`, `Implementing(fqn)`, and peers. + +### Violation + +A Predicate failure expressed as a structured record carrying the diagnostic ID, message, and source span. Violations accumulate per `ShapedSymbol`. `ToAnalyzer()` reports them; `ToProvider()` typically filters them out (generation proceeds only for clean Shapes). + +A Shape with any Violations is not *invalid* — it is a Shape whose consumer must decide what to do with the failures. + +### Disjunction (`OneOf`) + +`Stencil.OneOf(shape1, shape2, …)`. Passes when any alternative passes. When all fail, reports a **fused diagnostic** listing every unsatisfied expectation. + +Used when a symbol may legitimately take one of several valid forms (e.g. "must be a readonly partial record struct OR an abstract partial record"). + +### Quantifier + +Higher-order predicates that apply a sub-Shape across a Lens's output set. + +| Quantifier | Passes when | +| --- | --- | +| `All(lens, sub)` | Every navigated Focus satisfies `sub`. | +| `Any(lens, sub)` | At least one navigated Focus satisfies `sub`. | +| `None(lens, sub)` | No navigated Focus satisfies `sub`. | + +`All` is the common case and usually implicit in a Lens chain. `Any` and `None` make "at least one" and "none of" explicit. + +### Cross-focus Predicate + +A Predicate that compares two Foci reached by different navigation paths in the same Shape. Written with accessors like `SymbolEquals(a, b)` or `SameOriginalDefinition(a, b)`. + +Requires the query-comprehension form of the DSL so both Foci are captured as named variables, then a `SymbolEquals` predicate in the `where` clause. Needed for cyclic verifications — for example, confirming that a method's `[Handles]` type argument matches the event type the outer chain navigated to. + +### FocusPath *(planned)* + +A breadcrumb of every Lens / Prism hop that led to the current Focus. Written into `DiagnosticInfo.FocusPath`. Enables rich diagnostic messages like *"on type X, attribute `[Foo]` #2, type argument `T`: does not implement `IBar`"* without losing the root diagnostic's message format. + +--- + +## Ink — The Fixer Pipeline + +The third Materialisation consumer. Ink takes the diagnostics a Shape's analyzer produces and emits code fixes. + +### Ink + +The fixer infrastructure. Materialises a diagnostic stream as `CodeFixProvider`s. Shares an equivalence key per `FixKind` so fixers support "Fix All in Solution". + +### FixKind + +A built-in category of fix that Ink knows how to apply. Twenty-one shipped kinds cover modifiers (`MakePartial`, `MakeSealed`, …), base lists, attributes, visibility, and constructors. Each has a stable equivalence key. + +### `FixKind.Custom` + +Escape hatch for bespoke rewrites that don't fit a built-in kind. Paired with `Ink.WithCustomFix(tag, delegate)` to register the rewrite logic against a stable tag. + +--- + +## Member-Level Rules (shipped subset) + +The portion of the Shape DSL that navigates from a type to its members. The member-level primitives ship today; navigation to attributes / type arguments / base-type chains is the evolution described in [design-member-level-shapes.md](design-member-level-shapes.md). + +| Term | Definition | +| --- | --- | +| `MemberCheck` | A Predicate applied to each member returned by `Members(filter?)` (today: `ForEachMember`). | +| `MemberDiagnosticSpec` | The diagnostic descriptor (ID, message, severity) a `MemberCheck` reports when it fails. | +| `MemberSquiggleAt` | An enum that names the source span a member-level diagnostic targets: the identifier, the full declaration, the type annotation, or the first attribute. | +| `MemberSquiggleLocator` | The resolver that translates a `MemberSquiggleAt` into a concrete `Location`. | + +--- + +## Text Emission + +The pre-Shape-DSL half of Scribe. These primitives remain central for anything that generates source code — the Shape DSL tells you *what* to generate; these primitives are *how* you emit it. + +### Quill + +The fluent source builder at the heart of Scribe. Handles indentation, blank-line separation, using-directive collection, namespace resolution, and XML documentation. + +```csharp +Quill quill = Quill.Begin("global::MyNamespace.Generated"); +quill.Using("System") + .Blank() + .Line($"public sealed class {name}") + .Block(body => body.Line("...")); +string source = quill.Inscribe(); +``` + +The `Inscribe()` method finalises the builder and returns the generated source. Every output ends with `// I HAVE SPOKEN` — the generative act is complete. + +### Template + +A structured output scaffold used when the shape of the generated file is fixed but the contents vary. Cuts repetition when multiple generators emit near-identical file structures. + +### Naming + +Naming-convention helpers for generated identifiers. Converts between cases (`PascalCase`, `camelCase`, `snake_case`), derives generated type names from source symbols, and escapes C# keywords. + +### XmlDoc + +XML documentation extraction and generation utilities. Reads doc comments off Roslyn symbols and re-emits them (escaped, aligned, wrapped) onto generated members. + +### WellKnownFqns + +Constants for fully-qualified type names of BCL types (`System.String`, `System.Collections.Generic.IEnumerable`, etc.). Using constants avoids typos that silently break symbol lookups at compile time. + +### SyntaxPredicates + +Reusable predicate helpers for `ForAttributeWithMetadataName` and other syntax-level filters used inside `IncrementalGenerator` pipelines. Stable equality, cheap to evaluate. + +### NamespaceWalker + +Utility for walking namespace hierarchies in syntax trees. Resolves `using` directives, emits them with `global::` prefixes, and short-circuits `using` collapse where safe. + +### ScribeHeader + +Assembly-level attribute (`[assembly: ScribeHeader("…")]`) that brands every generated file with a decorative page header. Quill auto-discovers it via `GetCallingAssembly()`. + +--- + +## Build and Tooling + +### Stubs + +`netstandard2.0` polyfill types (`init`, `record`, `required`, nullable annotations) in `Stubs.cs`, guarded by `#if !NET5_0_OR_GREATER`. Analyzers must target `netstandard2.0` because the Roslyn compiler hosts them on .NET Framework-equivalent surfaces — these stubs let modern C# language features compile against that target. + +### LocalDev + +Scribe's build infrastructure for multi-repo local NuGet package development. A sentinel file activates props/targets that auto-pack on build, apply `-dev.` version suffixes, and generate a local NuGet source registration so sibling repos resolve newly-built packages without NuGet cache poisoning. + +### BulletsForHumanity.Scribe.Sdk + +The MSBuild SDK that configures analyzer/generator projects with zero boilerplate — sets the correct target framework, adds Roslyn references, enforces analyzer rules, and wires `EnforceExtendedAnalyzerRules`. + +--- + +## Cross-Reference + +| Vocabulary family | Home document | +| --- | --- | +| Stencil, Shape, Focus, Lens, Prism, Smudge, Etch, Materialisation | This glossary + [dsl.md](dsl.md) | +| Design rationale, planned primitives | [design-member-level-shapes.md](design-member-level-shapes.md) | +| Quill API details | [quill-reference.md](quill-reference.md), [architecture-quill.md](architecture-quill.md) | +| Transform → Register → Render pipeline | [writing-generators.md](writing-generators.md) | +| Project configuration, LocalDev | [project-setup.md](project-setup.md) | +| Build infrastructure internals | [architecture-infrastructure.md](architecture-infrastructure.md) | diff --git a/global.json b/global.json index 100b9c0..c6e5ae9 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ "allowPrerelease": false }, "msbuild-sdks": { - "BulletsForHumanity.Scribe.Sdk": "0.6.1" + "BulletsForHumanity.Scribe.Sdk": "0.6.2-dev.20260413-140444-648" } }