diff --git a/Directory.Build.props b/Directory.Build.props
index d6c6975..dd819b1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,6 +12,10 @@
true
false
+ net10.0
+ preview
+ enable
+ true
@@ -35,16 +39,14 @@
$(MSBuildThisFileDirectory).artifacts/
-
+
- net10.0
- preview
enable
- enable
nullable
Recommended
true
- true
true
$(NoWarn);NU1507;CS1591
diff --git a/Scribe.Ink/Scribe.Ink.csproj b/Scribe.Ink/Scribe.Ink.csproj
index 986d483..843fd13 100644
--- a/Scribe.Ink/Scribe.Ink.csproj
+++ b/Scribe.Ink/Scribe.Ink.csproj
@@ -1,26 +1,14 @@
-
-
+
- netstandard2.0
- 14
- true
- false
- Analyzer
- false
- embedded
- false
- true
+ Analyzer
+ enable
- $(NoWarn);NU5128;RS1038;RS2008
+ $(NoWarn);RS1038;RS2008
BulletsForHumanity.Scribe.Ink
Roslyn analyzers, code fix providers, and source generators for Scribe shapes. The scribe dips the quill in ink to mark, correct, and emend.
roslyn;analyzer;code-fix;source-generator;scribe
diff --git a/Scribe.Ink/Shapes/FixResolver.cs b/Scribe.Ink/Shapes/FixResolver.cs
new file mode 100644
index 0000000..f60a1a1
--- /dev/null
+++ b/Scribe.Ink/Shapes/FixResolver.cs
@@ -0,0 +1,32 @@
+using Scribe.Ink.Shapes.Fixes;
+using Scribe.Shapes;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// Maps a to its concrete
+/// implementation. Shared between and
+/// .
+///
+internal static class FixResolver
+{
+ public static IShapeFix? Resolve(FixKind kind) => kind switch
+ {
+ FixKind.AddPartialModifier => new AddPartialModifierFix(),
+ FixKind.AddSealedModifier => new AddSealedModifierFix(),
+ FixKind.AddInterfaceToBaseList => new AddInterfaceToBaseListFix(),
+ FixKind.AddAttribute => new AddAttributeFix(),
+ FixKind.AddAbstractModifier => new AddAbstractModifierFix(),
+ FixKind.RemoveAbstractModifier => new RemoveAbstractModifierFix(),
+ FixKind.AddStaticModifier => new AddStaticModifierFix(),
+ FixKind.AddBaseClass => new AddBaseClassFix(),
+ FixKind.RemoveFromBaseList => new RemoveFromBaseListFix(),
+ FixKind.RemovePartialModifier => new RemovePartialModifierFix(),
+ FixKind.RemoveSealedModifier => new RemoveSealedModifierFix(),
+ FixKind.RemoveStaticModifier => new RemoveStaticModifierFix(),
+ FixKind.RemoveAttribute => new RemoveAttributeFix(),
+ FixKind.SetVisibility => new SetVisibilityFix(),
+ FixKind.AddParameterlessConstructor => new AddParameterlessConstructorFix(),
+ _ => null,
+ };
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs b/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs
new file mode 100644
index 0000000..4155393
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs
@@ -0,0 +1,61 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddBaseClassFix : IShapeFix
+{
+ public string Title(Diagnostic diagnostic)
+ {
+ var name = BaseClassName(diagnostic);
+ return name is null ? "Add required base class" : $"Extend '{name}'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ var baseClassName = BaseClassName(diagnostic);
+ if (baseClassName is null)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var baseType = SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(baseClassName));
+
+ // Base class must come first in C#'s base list.
+ TypeDeclarationSyntax newTypeDecl;
+ if (typeDecl.BaseList is null)
+ {
+ newTypeDecl = typeDecl.WithBaseList(SyntaxFactory.BaseList(
+ SyntaxFactory.SingletonSeparatedList(baseType)));
+ }
+ else
+ {
+ var existing = typeDecl.BaseList.Types;
+ var withBase = existing.Insert(0, baseType);
+ newTypeDecl = typeDecl.WithBaseList(typeDecl.BaseList.WithTypes(withBase));
+ }
+
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ private static string? BaseClassName(Diagnostic diagnostic)
+ {
+ return diagnostic.Properties.TryGetValue("baseClass", out var name) && !string.IsNullOrEmpty(name)
+ ? name
+ : null;
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs b/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs
new file mode 100644
index 0000000..47ebc63
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs
@@ -0,0 +1,48 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddParameterlessConstructorFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Add public parameterless constructor";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var name = typeDecl.Identifier.ValueText;
+
+ var ctor = SyntaxFactory.ConstructorDeclaration(name)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
+ .WithParameterList(SyntaxFactory.ParameterList())
+ .WithBody(SyntaxFactory.Block());
+
+ var insertAt = 0;
+ for (var i = 0; i < typeDecl.Members.Count; i++)
+ {
+ if (typeDecl.Members[i] is FieldDeclarationSyntax)
+ {
+ insertAt = i + 1;
+ continue;
+ }
+ break;
+ }
+
+ var newTypeDecl = typeDecl.WithMembers(typeDecl.Members.Insert(insertAt, ctor));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs
new file mode 100644
index 0000000..e47e7e4
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs
@@ -0,0 +1,47 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class AddStaticModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Add 'static' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ if (typeDecl.Modifiers.Any(SyntaxKind.StaticKeyword))
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var staticToken = SyntaxFactory.Token(SyntaxKind.StaticKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+
+ var modifiers = typeDecl.Modifiers;
+ var insertAt = modifiers.Count;
+ for (var i = 0; i < modifiers.Count; i++)
+ {
+ if (modifiers[i].IsKind(SyntaxKind.PartialKeyword))
+ {
+ insertAt = i;
+ break;
+ }
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, staticToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs
new file mode 100644
index 0000000..e844dc7
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemoveAbstractModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Remove 'abstract' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ var abstractToken = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.AbstractKeyword));
+ if (abstractToken == default)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(abstractToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs
new file mode 100644
index 0000000..ffc30d5
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemoveAttributeFix : IShapeFix
+{
+ public string Title(Diagnostic diagnostic)
+ {
+ var name = AttributeName(diagnostic);
+ return name is null ? "Remove forbidden attribute" : $"Remove '[{Shorten(name)}]'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ var target = AttributeName(diagnostic);
+ if (target is null)
+ {
+ return document;
+ }
+
+ var shortName = Shorten(target);
+ var fullName = target;
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var newTypeDecl = typeDecl;
+
+ foreach (var list in typeDecl.AttributeLists)
+ {
+ var keepAttrs = list.Attributes
+ .Where(a => !Matches(a, fullName, shortName))
+ .ToArray();
+
+ if (keepAttrs.Length == list.Attributes.Count)
+ {
+ continue;
+ }
+
+ if (keepAttrs.Length == 0)
+ {
+ newTypeDecl = newTypeDecl.WithAttributeLists(
+ newTypeDecl.AttributeLists.Remove(list));
+ }
+ else
+ {
+ var newList = list.WithAttributes(SyntaxFactory.SeparatedList(keepAttrs));
+ newTypeDecl = newTypeDecl.WithAttributeLists(
+ newTypeDecl.AttributeLists.Replace(list, newList));
+ }
+ }
+
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ private static bool Matches(AttributeSyntax attribute, string full, string shortName)
+ {
+ var text = attribute.Name.ToString();
+ var bareShort = Shorten(shortName);
+ return string.Equals(text, full, StringComparison.Ordinal)
+ || string.Equals(text, shortName, StringComparison.Ordinal)
+ || string.Equals(text, bareShort, StringComparison.Ordinal)
+ || text.EndsWith("." + shortName, StringComparison.Ordinal)
+ || text.EndsWith("." + bareShort, StringComparison.Ordinal);
+ }
+
+ private static string? AttributeName(Diagnostic diagnostic)
+ {
+ return diagnostic.Properties.TryGetValue("attribute", out var name) && !string.IsNullOrEmpty(name)
+ ? name
+ : null;
+ }
+
+ private static string Shorten(string name)
+ {
+ const string Suffix = "Attribute";
+ return name.EndsWith(Suffix, StringComparison.Ordinal)
+ ? name.Substring(0, name.Length - Suffix.Length)
+ : name;
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs
new file mode 100644
index 0000000..23a1135
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemoveFromBaseListFix : IShapeFix
+{
+ public string Title(Diagnostic diagnostic)
+ {
+ var name = TargetName(diagnostic);
+ return name is null ? "Remove forbidden base type" : $"Remove '{name}'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ if (typeDecl.BaseList is null)
+ {
+ return document;
+ }
+
+ var target = TargetName(diagnostic);
+ if (target is null)
+ {
+ return document;
+ }
+
+ var simpleName = ShortName(target);
+ var match = typeDecl.BaseList.Types.FirstOrDefault(t => MatchesTypeName(t, target, simpleName));
+ if (match is null)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var remaining = typeDecl.BaseList.Types.Remove(match);
+ TypeDeclarationSyntax newTypeDecl = remaining.Count == 0
+ ? typeDecl.WithBaseList(null)
+ : typeDecl.WithBaseList(typeDecl.BaseList.WithTypes(remaining));
+
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ private static string? TargetName(Diagnostic diagnostic)
+ {
+ if (diagnostic.Properties.TryGetValue("interface", out var i) && !string.IsNullOrEmpty(i))
+ {
+ return i;
+ }
+
+ return diagnostic.Properties.TryGetValue("baseClass", out var b) && !string.IsNullOrEmpty(b)
+ ? b
+ : null;
+ }
+
+ private static string ShortName(string metadataName)
+ {
+ var lastDot = metadataName.LastIndexOf('.');
+ return lastDot < 0 ? metadataName : metadataName.Substring(lastDot + 1);
+ }
+
+ private static bool MatchesTypeName(BaseTypeSyntax baseType, string full, string simple)
+ {
+ var text = baseType.Type.ToString();
+ return string.Equals(text, full, StringComparison.Ordinal)
+ || string.Equals(text, simple, StringComparison.Ordinal)
+ || text.EndsWith("." + simple, StringComparison.Ordinal);
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs
new file mode 100644
index 0000000..b3ab4f3
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemovePartialModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Remove 'partial' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.PartialKeyword));
+ if (token == default)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs
new file mode 100644
index 0000000..9ae1c65
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemoveSealedModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Remove 'sealed' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.SealedKeyword));
+ if (token == default)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs
new file mode 100644
index 0000000..5674f0d
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class RemoveStaticModifierFix : IShapeFix
+{
+ public string Title(Diagnostic _) => "Remove 'static' modifier";
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic _,
+ CancellationToken ct)
+ {
+ var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.StaticKeyword));
+ if (token == default)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+}
diff --git a/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs b/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs
new file mode 100644
index 0000000..dd25c32
--- /dev/null
+++ b/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Scribe.Ink.Shapes.Fixes;
+
+internal sealed class SetVisibilityFix : IShapeFix
+{
+ private static readonly SyntaxKind[] VisibilityKinds =
+ {
+ SyntaxKind.PublicKeyword,
+ SyntaxKind.InternalKeyword,
+ SyntaxKind.PrivateKeyword,
+ SyntaxKind.ProtectedKeyword,
+ };
+
+ public string Title(Diagnostic diagnostic)
+ {
+ var target = Target(diagnostic);
+ return target is null ? "Change visibility" : $"Make '{target}'";
+ }
+
+ public async Task FixAsync(
+ Document document,
+ TypeDeclarationSyntax typeDecl,
+ Diagnostic diagnostic,
+ CancellationToken ct)
+ {
+ var target = Target(diagnostic);
+ if (target is null)
+ {
+ return document;
+ }
+
+ if (!TryGetKeywordKind(target, out var targetKind))
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ var modifiers = typeDecl.Modifiers;
+
+ var kept = new List(modifiers.Count);
+ var firstVisibilityTrivia = default(SyntaxTriviaList);
+ var replaced = false;
+ foreach (var token in modifiers)
+ {
+ if (IsVisibilityKind(token.Kind()))
+ {
+ if (!replaced)
+ {
+ firstVisibilityTrivia = token.LeadingTrivia;
+ replaced = true;
+ }
+ continue;
+ }
+ kept.Add(token);
+ }
+
+ var newToken = SyntaxFactory.Token(targetKind)
+ .WithTrailingTrivia(SyntaxFactory.Space);
+
+ if (replaced)
+ {
+ newToken = newToken.WithLeadingTrivia(firstVisibilityTrivia);
+ kept.Insert(0, newToken);
+ }
+ else
+ {
+ var existingLeading = typeDecl.GetLeadingTrivia();
+ newToken = newToken.WithLeadingTrivia(existingLeading);
+ var newTypeDecl = typeDecl
+ .WithoutLeadingTrivia()
+ .WithModifiers(modifiers.Insert(0, newToken));
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
+ }
+
+ var newModifiers = SyntaxFactory.TokenList(kept);
+ var updated = typeDecl.WithModifiers(newModifiers);
+ return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, updated));
+ }
+
+ private static bool IsVisibilityKind(SyntaxKind kind)
+ {
+ foreach (var k in VisibilityKinds)
+ {
+ if (k == kind) return true;
+ }
+ return false;
+ }
+
+ private static bool TryGetKeywordKind(string visibility, out SyntaxKind kind)
+ {
+ switch (visibility)
+ {
+ case "public":
+ kind = SyntaxKind.PublicKeyword;
+ return true;
+ case "internal":
+ kind = SyntaxKind.InternalKeyword;
+ return true;
+ case "private":
+ kind = SyntaxKind.PrivateKeyword;
+ return true;
+ default:
+ kind = default;
+ return false;
+ }
+ }
+
+ private static string? Target(Diagnostic diagnostic)
+ {
+ return diagnostic.Properties.TryGetValue("visibility", out var v) && !string.IsNullOrEmpty(v)
+ ? v
+ : null;
+ }
+}
diff --git a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs
index ca00f35..eb11c76 100644
--- a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs
+++ b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs
@@ -24,7 +24,7 @@ internal sealed class ShapeCodeFixProvider : CodeFixProvider
public override ImmutableArray FixableDiagnosticIds => _ids;
- public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+ public override FixAllProvider? GetFixAllProvider() => ShapeFixAllProvider.Instance;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
@@ -45,7 +45,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
continue;
}
- var fix = ResolveFix(fixKind);
+ var fix = FixResolver.Resolve(fixKind);
if (fix is null)
{
continue;
@@ -67,12 +67,4 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
}
}
- private static IShapeFix? ResolveFix(FixKind kind) => kind switch
- {
- FixKind.AddPartialModifier => new AddPartialModifierFix(),
- FixKind.AddSealedModifier => new AddSealedModifierFix(),
- FixKind.AddInterfaceToBaseList => new AddInterfaceToBaseListFix(),
- FixKind.AddAttribute => new AddAttributeFix(),
- _ => null,
- };
}
diff --git a/Scribe.Ink/Shapes/ShapeFixAllProvider.cs b/Scribe.Ink/Shapes/ShapeFixAllProvider.cs
new file mode 100644
index 0000000..04f5b13
--- /dev/null
+++ b/Scribe.Ink/Shapes/ShapeFixAllProvider.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Scribe.Ink.Shapes.Fixes;
+using Scribe.Shapes;
+
+namespace Scribe.Ink.Shapes;
+
+///
+/// Custom that chains Shape fixes targeting the
+/// same type declaration. The built-in
+/// applies each fix to the original document and merges the results, which
+/// fails when multiple diagnostics touch the same node. This provider applies
+/// fixes sequentially per-type using a to
+/// re-locate the type declaration across edits.
+///
+internal sealed class ShapeFixAllProvider : FixAllProvider
+{
+ public static readonly ShapeFixAllProvider Instance = new();
+
+ private ShapeFixAllProvider() { }
+
+ public override IEnumerable GetSupportedFixAllScopes()
+ => new[] { FixAllScope.Document, FixAllScope.Project, FixAllScope.Solution };
+
+ public override Task GetFixAsync(FixAllContext context)
+ {
+ var title = context.Scope switch
+ {
+ FixAllScope.Document => "Apply Shape fixes (document)",
+ FixAllScope.Project => "Apply Shape fixes (project)",
+ FixAllScope.Solution => "Apply Shape fixes (solution)",
+ _ => "Apply Shape fixes",
+ };
+
+ return Task.FromResult(CodeAction.Create(
+ title: title,
+ createChangedSolution: ct => FixAllAsync(context, ct),
+ equivalenceKey: "Shape.FixAll." + context.Scope));
+ }
+
+ private static async Task FixAllAsync(FixAllContext context, CancellationToken ct)
+ {
+ var solution = context.Solution;
+
+ var documents = CollectDocuments(context, ct);
+
+ foreach (var document in documents)
+ {
+ ct.ThrowIfCancellationRequested();
+ var diagnostics = await context.GetDocumentDiagnosticsAsync(document).ConfigureAwait(false);
+ if (diagnostics.IsDefaultOrEmpty)
+ {
+ continue;
+ }
+
+ var updated = await FixDocumentAsync(document, diagnostics, ct).ConfigureAwait(false);
+ if (updated is null)
+ {
+ continue;
+ }
+
+ solution = updated.Project.Solution;
+ }
+
+ return solution;
+ }
+
+ private static IReadOnlyList CollectDocuments(
+ FixAllContext context, CancellationToken ct)
+ {
+ switch (context.Scope)
+ {
+ case FixAllScope.Document when context.Document is not null:
+ return new[] { context.Document };
+
+ case FixAllScope.Project when context.Project is not null:
+ return context.Project.Documents.ToArray();
+
+ case FixAllScope.Solution:
+ var all = new List();
+ foreach (var project in context.Solution.Projects)
+ {
+ ct.ThrowIfCancellationRequested();
+ all.AddRange(project.Documents);
+ }
+ return all;
+
+ default:
+ return Array.Empty();
+ }
+ }
+
+ private static async Task FixDocumentAsync(
+ Document document,
+ ImmutableArray diagnostics,
+ CancellationToken ct)
+ {
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (root is null)
+ {
+ return null;
+ }
+
+ // Group diagnostics by the type declaration they target, then annotate
+ // each type so we can re-locate it after every edit.
+ var perType = new Dictionary>();
+ foreach (var diagnostic in diagnostics)
+ {
+ ct.ThrowIfCancellationRequested();
+ var node = root.FindNode(diagnostic.Location.SourceSpan);
+ var typeDecl = node.AncestorsAndSelf().OfType().FirstOrDefault();
+ if (typeDecl is null)
+ {
+ continue;
+ }
+
+ if (!perType.TryGetValue(typeDecl, out var list))
+ {
+ list = new List();
+ perType[typeDecl] = list;
+ }
+ list.Add(diagnostic);
+ }
+
+ if (perType.Count == 0)
+ {
+ return null;
+ }
+
+ var annotations = new Dictionary>();
+ var perTypeAnnotations = new Dictionary();
+ foreach (var entry in perType)
+ {
+ var annotation = new SyntaxAnnotation("Scribe.Shape.FixAll", Guid.NewGuid().ToString("N"));
+ annotations[annotation] = entry.Value;
+ perTypeAnnotations[entry.Key] = annotation;
+ }
+
+ var annotated = root.ReplaceNodes(
+ perType.Keys,
+ (original, _) => original.WithAdditionalAnnotations(perTypeAnnotations[original]));
+
+ var workingDoc = document.WithSyntaxRoot(annotated);
+
+ foreach (var pair in annotations)
+ {
+ var annotation = pair.Key;
+ foreach (var diagnostic in pair.Value)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (!TryResolveFix(diagnostic, out var fix))
+ {
+ continue;
+ }
+
+ var currentRoot = await workingDoc.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (currentRoot is null)
+ {
+ continue;
+ }
+
+ var typeDecl = currentRoot
+ .GetAnnotatedNodes(annotation)
+ .OfType()
+ .FirstOrDefault();
+ if (typeDecl is null)
+ {
+ continue;
+ }
+
+ workingDoc = await fix.FixAsync(workingDoc, typeDecl, diagnostic, ct).ConfigureAwait(false);
+ }
+ }
+
+ return workingDoc;
+ }
+
+ private static bool TryResolveFix(Diagnostic diagnostic, out IShapeFix fix)
+ {
+ fix = null!;
+ if (!diagnostic.Properties.TryGetValue("fixKind", out var fixKindStr)
+ || string.IsNullOrEmpty(fixKindStr)
+ || !Enum.TryParse(fixKindStr, out var fixKind)
+ || fixKind == FixKind.None)
+ {
+ return false;
+ }
+
+ var resolved = FixResolver.Resolve(fixKind);
+ if (resolved is null)
+ {
+ return false;
+ }
+
+ fix = resolved;
+ return true;
+ }
+}
diff --git a/Scribe.Scriptorium/Scribe.Scriptorium.csproj b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
index a5a48bd..957f33a 100644
--- a/Scribe.Scriptorium/Scribe.Scriptorium.csproj
+++ b/Scribe.Scriptorium/Scribe.Scriptorium.csproj
@@ -1,22 +1,13 @@
-
+
+
+
- netstandard2.0
- 14
- true
- false
- false
- false
- embedded
- false
+ Meta
@@ -26,4 +17,20 @@
+
+
+
+
+
+ false
+ false
+
+
diff --git a/Scribe.Sdk/Scribe.Sdk.csproj b/Scribe.Sdk/Scribe.Sdk.csproj
index 7f26993..0566cde 100644
--- a/Scribe.Sdk/Scribe.Sdk.csproj
+++ b/Scribe.Sdk/Scribe.Sdk.csproj
@@ -9,6 +9,8 @@
BulletsForHumanity.Scribe.Sdk
netstandard2.0
+
+ latest
false
true
diff --git a/Scribe.Sdk/Sdk/Phases/Analyzer.targets b/Scribe.Sdk/Sdk/Phases/Analyzer.targets
index cc92b1c..4d98d32 100644
--- a/Scribe.Sdk/Sdk/Phases/Analyzer.targets
+++ b/Scribe.Sdk/Sdk/Phases/Analyzer.targets
@@ -19,6 +19,13 @@
false
Analyzer
true
+
+ $(NoWarn);RS1038
diff --git a/Scribe.Sdk/Sdk/Phases/Companion.targets b/Scribe.Sdk/Sdk/Phases/Companion.targets
index cce0024..bd863b1 100644
--- a/Scribe.Sdk/Sdk/Phases/Companion.targets
+++ b/Scribe.Sdk/Sdk/Phases/Companion.targets
@@ -18,7 +18,7 @@
Usage in a Companion .csproj:
Companion
- MyOrg.MyLib
+ MyOrg.MyLib (metadata only)
-->
diff --git a/Scribe.Sdk/Sdk/Phases/Meta.targets b/Scribe.Sdk/Sdk/Phases/Meta.targets
index 20f2ad1..84f4aef 100644
--- a/Scribe.Sdk/Sdk/Phases/Meta.targets
+++ b/Scribe.Sdk/Sdk/Phases/Meta.targets
@@ -12,6 +12,11 @@
that consuming projects resolve via a PackageVersion override emitted to
$(ArtifactsPath).Directory.Packages.targets.
+ Opt out of auto-pack with false — useful for
+ solution-internal Meta projects consumed purely via ProjectReference
+ that should never be packaged at all. The override-file emission is
+ also skipped.
+
The consumer's Directory.Build.targets must import override files via
wildcard:
in Sdk.targets). No per-node conditions needed inside. -->
-
+
false
Analyzer
true
$(GetTargetPathDependsOn);GetDependencyTargetPaths
+
+ false
+
+ false
+
+
+
+
+
<_ScribeMetaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
0.0.0-dev.$(_ScribeMetaTimestamp)
0.0.0-dev.$(_ScribeMetaTimestamp)
-
-
- true
- true
false
@@ -54,6 +65,16 @@
<_ScribeMetaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)'))
$(_ScribeMetaArtifactsDir)packages\
+
+
+
+
$(RestoreAdditionalProjectSources);$(_ScribeMetaArtifactsDir)packages
diff --git a/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs
new file mode 100644
index 0000000..2c04202
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Ink.Shapes;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Validates that the custom ShapeFixAllProvider chains multiple
+/// Shape fixes that target the same type declaration — the scenario the
+/// built-in BatchFixer cannot handle.
+///
+public class ShapeFixAllProviderTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public async Task FixAll_chains_partial_and_sealed_fixes_on_same_type()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .MustBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await RunFixAll(shape, "public class Widget { }", FixAllScope.Document);
+
+ result.ShouldContain("partial");
+ result.ShouldContain("sealed");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public async Task FixAll_chains_visibility_plus_remove_modifier_fixes()
+ {
+ var shape = Shape.Class()
+ .MustBePublic()
+ .MustNotBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await RunFixAll(shape, "internal sealed class Widget { }", FixAllScope.Document);
+
+ result.ShouldContain("public");
+ result.ShouldNotContain("internal");
+ result.ShouldNotContain("sealed");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public async Task FixAll_applies_fixes_per_type_independently()
+ {
+ var shape = Shape.Class()
+ .MustBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public class Widget { }
+public class Gadget { }
+";
+ var result = await RunFixAll(shape, source, FixAllScope.Document);
+
+ result.ShouldContain("partial class Widget");
+ result.ShouldContain("partial class Gadget");
+ }
+
+ private static async Task RunFixAll(
+ Shape shape, string source, FixAllScope scope)
+ where TModel : IEquatable
+ {
+ using var workspace = new AdhocWorkspace();
+ var project = workspace.AddProject("P", LanguageNames.CSharp)
+ .WithMetadataReferences(AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)));
+ var document = project.AddDocument("T.cs", SourceText.From(source));
+ project = document.Project;
+
+ var compilation = await project.GetCompilationAsync().ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Compilation unavailable.");
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ diagnostics.Length.ShouldBeGreaterThan(0);
+
+ var fixProvider = shape.ToFixProvider();
+ var fixAll = fixProvider.GetFixAllProvider();
+ fixAll.ShouldNotBeNull();
+
+ var context = new FixAllContext(
+ document: document,
+ codeFixProvider: fixProvider,
+ scope: scope,
+ codeActionEquivalenceKey: "Shape.FixAll." + scope,
+ diagnosticIds: fixProvider.FixableDiagnosticIds,
+ fixAllDiagnosticProvider: new TestDiagnosticProvider(diagnostics),
+ cancellationToken: CancellationToken.None);
+
+ var action = await fixAll.GetFixAsync(context).ConfigureAwait(false);
+ action.ShouldNotBeNull();
+
+ var operations = await action.GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
+ var applied = operations.OfType().First();
+ var newDoc = applied.ChangedSolution.GetDocument(document.Id)!;
+ var text = await newDoc.GetTextAsync().ConfigureAwait(false);
+ return text.ToString();
+ }
+
+ private sealed class TestDiagnosticProvider : FixAllContext.DiagnosticProvider
+ {
+ private readonly ImmutableArray _diagnostics;
+
+ public TestDiagnosticProvider(ImmutableArray diagnostics)
+ => _diagnostics = diagnostics;
+
+ public override Task> GetAllDiagnosticsAsync(Project project, CancellationToken ct)
+ => Task.FromResult>(_diagnostics);
+
+ public override Task> GetDocumentDiagnosticsAsync(Document document, CancellationToken ct)
+ => Task.FromResult>(_diagnostics);
+
+ public override Task> GetProjectDiagnosticsAsync(Project project, CancellationToken ct)
+ => Task.FromResult>(Enumerable.Empty());
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs
new file mode 100644
index 0000000..57db68a
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs
@@ -0,0 +1,315 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Ink.Shapes;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Exercises the Phase 8 extensions to the MustBe*/MustNot*
+/// catalog: analyzer emissions for SCRIBE006–012 and the matching code
+/// fix application. Each test pairs an analyzer assertion with a fix
+/// assertion where a fix exists.
+///
+public class ShapePhase8PrimitivesTests
+{
+ private readonly record struct Collected(string Fqn);
+
+ [Fact]
+ public void MustBeAbstract_emits_SCRIBE006_with_AddAbstractModifier_fix_kind()
+ {
+ var shape = Shape.Class()
+ .MustBeAbstract()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE006");
+ diagnostics[0].Properties["fixKind"].ShouldBe("AddAbstractModifier");
+ }
+
+ [Fact]
+ public void MustBeAbstract_emits_nothing_when_already_abstract()
+ {
+ var shape = Shape.Class()
+ .MustBeAbstract()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public abstract class Widget { }").ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task MustBeAbstract_fix_adds_abstract_keyword()
+ {
+ var shape = Shape.Class()
+ .MustBeAbstract()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public class Widget { }");
+ result.ShouldContain("abstract class Widget");
+ }
+
+ [Fact]
+ public void MustBeStatic_emits_SCRIBE007_with_AddStaticModifier_fix_kind()
+ {
+ var shape = Shape.Class()
+ .MustBeStatic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE007");
+ diagnostics[0].Properties["fixKind"].ShouldBe("AddStaticModifier");
+ }
+
+ [Fact]
+ public async Task MustBeStatic_fix_adds_static_keyword()
+ {
+ var shape = Shape.Class()
+ .MustBeStatic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public class Widget { }");
+ result.ShouldContain("static class Widget");
+ }
+
+ [Fact]
+ public void MustExtend_emits_SCRIBE008_and_encodes_base_class_in_fix_properties()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("MarkerAttribute")
+ .MustExtend("MyBase")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+public class MyBase { }
+[Marker] public class Widget { }
+";
+ var diagnostics = RunAnalyzer(shape, source)
+ .Where(d => d.Id == "SCRIBE008")
+ .ToArray();
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Properties["fixKind"].ShouldBe("AddBaseClass");
+ diagnostics[0].Properties["baseClass"].ShouldBe("MyBase");
+ }
+
+ [Fact]
+ public void MustExtend_emits_nothing_when_already_extends()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("MarkerAttribute")
+ .MustExtend("MyBase")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+public class MyBase { }
+[Marker] public class Widget : MyBase { }
+";
+ RunAnalyzer(shape, source).Where(d => d.Id == "SCRIBE008").ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task MustExtend_fix_inserts_base_class_at_head_of_base_list()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("MarkerAttribute")
+ .MustExtend("MyBase")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+public class MyBase { }
+[Marker] public class Widget : System.IComparable { public int CompareTo(object o) => 0; }
+";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldContain("Widget : MyBase");
+ result.ShouldContain("IComparable");
+ }
+
+ [Fact]
+ public void MustBeInNamespace_emits_SCRIBE009_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+namespace MyApp.Other { public class Widget { } }
+";
+ var diagnostics = RunAnalyzer(shape, source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE009");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustBeInNamespace_allows_matching_namespace()
+ {
+ var shape = Shape.Class()
+ .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+namespace MyApp.Domain.Things { public class Widget { } }
+";
+ RunAnalyzer(shape, source).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustNotBeAbstract_emits_SCRIBE010_with_RemoveAbstractModifier_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBeAbstract()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public abstract class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE010");
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveAbstractModifier");
+ }
+
+ [Fact]
+ public async Task MustNotBeAbstract_fix_removes_abstract_keyword()
+ {
+ var shape = Shape.Class()
+ .MustNotBeAbstract()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public abstract class Widget { }");
+ result.ShouldNotContain("abstract");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public void MustNotBeGeneric_emits_SCRIBE011_with_no_fix_on_generic_class()
+ {
+ var shape = Shape.Class()
+ .MustNotBeGeneric()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE011");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustNotBeGeneric_allows_non_generic_class()
+ {
+ var shape = Shape.Class()
+ .MustNotBeGeneric()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustNotImplement_emits_SCRIBE012_and_encodes_interface_for_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget : System.IDisposable { public void Dispose() { } }";
+ var diagnostics = RunAnalyzer(shape, source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE012");
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveFromBaseList");
+ diagnostics[0].Properties["interface"].ShouldBe("System.IDisposable");
+ }
+
+ [Fact]
+ public async Task MustNotImplement_fix_removes_forbidden_interface_from_base_list()
+ {
+ var shape = Shape.Class()
+ .MustNotImplement("System.IDisposable")
+ .Project((in ShapeProjectionContext 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);
+ result.ShouldContain("IComparable");
+ result.ShouldNotContain("IDisposable");
+ }
+
+ private static ImmutableArray RunAnalyzer(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ var compilation = Compile(source);
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult();
+ }
+
+ private static CSharpCompilation Compile(string source)
+ {
+ var tree = CSharpSyntaxTree.ParseText(source);
+ var refs = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
+ .ToList();
+ return CSharpCompilation.Create(
+ assemblyName: "TestAssembly",
+ syntaxTrees: [tree],
+ references: refs,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ private static async Task ApplyFirstFix(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ using var workspace = new AdhocWorkspace();
+ var project = workspace.AddProject("P", LanguageNames.CSharp)
+ .WithMetadataReferences(AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)));
+ var document = project.AddDocument("T.cs", SourceText.From(source));
+ project = document.Project;
+
+ var compilation = await project.GetCompilationAsync().ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Compilation unavailable.");
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var first = diagnostics
+ .OrderBy(d => d.Location.SourceSpan.Start)
+ .First(d => d.Properties.TryGetValue("fixKind", out var k)
+ && !string.IsNullOrEmpty(k)
+ && k != "None");
+
+ var fixProvider = shape.ToFixProvider();
+ var actions = new List();
+ var context = new CodeFixContext(
+ document,
+ first,
+ (action, _) => actions.Add(action),
+ CancellationToken.None);
+ await fixProvider.RegisterCodeFixesAsync(context).ConfigureAwait(false);
+
+ actions.Count.ShouldBeGreaterThan(0);
+
+ var operations = await actions[0].GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
+ var applied = operations.OfType().First();
+ var newDoc = applied.ChangedSolution.GetDocument(document.Id)!;
+ var text = await newDoc.GetTextAsync().ConfigureAwait(false);
+ return text.ToString();
+ }
+}
diff --git a/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs
new file mode 100644
index 0000000..f0fe52b
--- /dev/null
+++ b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs
@@ -0,0 +1,491 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using Scribe.Ink.Shapes;
+using Scribe.Shapes;
+using Shouldly;
+using Xunit;
+
+namespace Scribe.Tests.Shapes;
+
+///
+/// Exercises the Phase 8.5 additions to the MustBe*/MustNot*
+/// catalog: direct negations (SCRIBE013–020), visibility (SCRIBE021–026),
+/// declaration kind (SCRIBE027–030), and parameterless constructor
+/// (SCRIBE031), along with the matching fix implementations.
+///
+public class ShapePhase8_5Tests
+{
+ private readonly record struct Collected(string Fqn);
+
+ // ───────────────────────────────────────────────────────────────
+ // Direct negations
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustNotBePartial_emits_SCRIBE013_with_RemovePartialModifier_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public partial class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE013");
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemovePartialModifier");
+ }
+
+ [Fact]
+ public async Task MustNotBePartial_fix_removes_partial_keyword()
+ {
+ var shape = Shape.Class()
+ .MustNotBePartial()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public partial class Widget { }");
+ result.ShouldNotContain("partial");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public void MustNotBeSealed_emits_SCRIBE014_with_RemoveSealedModifier_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public sealed class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE014");
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveSealedModifier");
+ }
+
+ [Fact]
+ public async Task MustNotBeSealed_fix_removes_sealed_keyword()
+ {
+ var shape = Shape.Class()
+ .MustNotBeSealed()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public sealed class Widget { }");
+ result.ShouldNotContain("sealed");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public void MustNotHaveAttribute_emits_SCRIBE015_with_RemoveAttribute_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotHaveAttribute("MarkerAttribute")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+[Marker] public class Widget { }
+";
+ var diagnostics = RunAnalyzer(shape, source)
+ .Where(d => d.Id == "SCRIBE015")
+ .ToArray();
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveAttribute");
+ diagnostics[0].Properties["attribute"].ShouldBe("MarkerAttribute");
+ }
+
+ [Fact]
+ public async Task MustNotHaveAttribute_fix_removes_attribute_from_type()
+ {
+ var shape = Shape.Class()
+ .MustNotHaveAttribute("MarkerAttribute")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+[Marker] public class Widget { }
+";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldNotContain("[Marker]");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public void MustNotBeNamed_emits_SCRIBE016_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBeNamed(@"^.*Impl$")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class WidgetImpl { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE016");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustNotBeStatic_emits_SCRIBE017_with_RemoveStaticModifier_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBeStatic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public static class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE017");
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveStaticModifier");
+ }
+
+ [Fact]
+ public async Task MustNotBeStatic_fix_removes_static_keyword()
+ {
+ var shape = Shape.Class()
+ .MustNotBeStatic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public static class Widget { }");
+ result.ShouldNotContain("static");
+ result.ShouldContain("class Widget");
+ }
+
+ [Fact]
+ public void MustNotExtend_emits_SCRIBE018_and_encodes_baseClass_for_fix()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("MarkerAttribute")
+ .MustNotExtend("MyBase")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+public class MyBase { }
+[Marker] public class Widget : MyBase { }
+";
+ var diagnostics = RunAnalyzer(shape, source)
+ .Where(d => d.Id == "SCRIBE018")
+ .ToArray();
+
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Properties["fixKind"].ShouldBe("RemoveFromBaseList");
+ diagnostics[0].Properties["baseClass"].ShouldBe("MyBase");
+ }
+
+ [Fact]
+ public async Task MustNotExtend_fix_removes_base_class_from_base_list()
+ {
+ var shape = Shape.Class()
+ .MustHaveAttribute("MarkerAttribute")
+ .MustNotExtend("MyBase")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = @"
+public sealed class MarkerAttribute : System.Attribute { }
+public class MyBase { }
+[Marker] public class Widget : MyBase, System.IComparable { public int CompareTo(object o) => 0; }
+";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldNotContain("MyBase,");
+ result.ShouldNotContain(": MyBase");
+ result.ShouldContain("IComparable");
+ }
+
+ [Fact]
+ public void MustNotBeInNamespace_emits_SCRIBE019_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBeInNamespace(@"^MyApp\.Legacy(\..*)?$")
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "namespace MyApp.Legacy { public class Widget { } }";
+ var diagnostics = RunAnalyzer(shape, source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE019");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustBeGeneric_emits_SCRIBE020_on_non_generic_class_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustBeGeneric()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE020");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustBeGeneric_allows_generic_class()
+ {
+ var shape = Shape.Class()
+ .MustBeGeneric()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Visibility
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustBePublic_emits_SCRIBE021_with_SetVisibility_fix_and_visibility_property()
+ {
+ var shape = Shape.Class()
+ .MustBePublic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "internal class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE021");
+ diagnostics[0].Properties["fixKind"].ShouldBe("SetVisibility");
+ diagnostics[0].Properties["visibility"].ShouldBe("public");
+ }
+
+ [Fact]
+ public async Task MustBePublic_fix_rewrites_visibility_to_public()
+ {
+ var shape = Shape.Class()
+ .MustBePublic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "internal class Widget { }");
+ result.ShouldContain("public class Widget");
+ result.ShouldNotContain("internal class");
+ }
+
+ [Fact]
+ public void MustBeInternal_emits_SCRIBE022_with_SetVisibility_fix()
+ {
+ var shape = Shape.Class()
+ .MustBeInternal()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE022");
+ diagnostics[0].Properties["visibility"].ShouldBe("internal");
+ }
+
+ [Fact]
+ public async Task MustBeInternal_fix_rewrites_visibility_to_internal()
+ {
+ var shape = Shape.Class()
+ .MustBeInternal()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var result = await ApplyFirstFix(shape, "public class Widget { }");
+ result.ShouldContain("internal class Widget");
+ result.ShouldNotContain("public class");
+ }
+
+ [Fact]
+ public void MustNotBePublic_emits_SCRIBE024_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustNotBePublic()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE024");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustNotBeInternal_allows_public_class()
+ {
+ var shape = Shape.Class()
+ .MustNotBeInternal()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty();
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Declaration kind
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustBeRecord_emits_SCRIBE027_on_plain_class_with_no_fix()
+ {
+ var shape = Shape.Class()
+ .MustBeRecord()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE027");
+ diagnostics[0].Properties["fixKind"].ShouldBe("None");
+ }
+
+ [Fact]
+ public void MustBeRecord_allows_record_class()
+ {
+ var shape = Shape.Class()
+ .MustBeRecord()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public record class Widget(int X);").ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustNotBeRecord_emits_SCRIBE028_on_record()
+ {
+ var shape = Shape.Record()
+ .MustNotBeRecord()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public record class Widget(int X);");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE028");
+ }
+
+ [Fact]
+ public void MustBeValueType_emits_SCRIBE029_on_class_and_skips_struct()
+ {
+ var shape = Shape.Struct()
+ .MustBeValueType()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public struct Widget { }").ShouldBeEmpty();
+
+ var shapeClass = Shape.Class()
+ .MustBeValueType()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shapeClass, "public class Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE029");
+ }
+
+ [Fact]
+ public void MustNotBeValueType_emits_SCRIBE030_on_struct()
+ {
+ var shape = Shape.Struct()
+ .MustNotBeValueType()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var diagnostics = RunAnalyzer(shape, "public struct Widget { }");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE030");
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Parameterless constructor
+ // ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MustHaveParameterlessConstructor_allows_class_with_no_explicit_ctors()
+ {
+ var shape = Shape.Class()
+ .MustHaveParameterlessConstructor()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MustHaveParameterlessConstructor_emits_SCRIBE031_when_only_parameterised_ctor_exists()
+ {
+ var shape = Shape.Class()
+ .MustHaveParameterlessConstructor()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { public Widget(int x) { } }";
+ var diagnostics = RunAnalyzer(shape, source);
+ diagnostics.Length.ShouldBe(1);
+ diagnostics[0].Id.ShouldBe("SCRIBE031");
+ diagnostics[0].Properties["fixKind"].ShouldBe("AddParameterlessConstructor");
+ }
+
+ [Fact]
+ public async Task MustHaveParameterlessConstructor_fix_adds_public_parameterless_ctor()
+ {
+ var shape = Shape.Class()
+ .MustHaveParameterlessConstructor()
+ .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn));
+
+ var source = "public class Widget { public Widget(int x) { } }";
+ var result = await ApplyFirstFix(shape, source);
+ result.ShouldContain("public Widget()");
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Harness
+ // ───────────────────────────────────────────────────────────────
+
+ 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));
+ }
+
+ private static async Task ApplyFirstFix(Shape shape, string source)
+ where TModel : IEquatable
+ {
+ using var workspace = new AdhocWorkspace();
+ var project = workspace.AddProject("P", LanguageNames.CSharp)
+ .WithMetadataReferences(AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
+ .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)));
+ var document = project.AddDocument("T.cs", SourceText.From(source));
+ project = document.Project;
+
+ var compilation = await project.GetCompilationAsync().ConfigureAwait(false)
+ ?? throw new InvalidOperationException("Compilation unavailable.");
+ var analyzer = shape.ToAnalyzer();
+ var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var first = diagnostics
+ .OrderBy(d => d.Location.SourceSpan.Start)
+ .First(d => d.Properties.TryGetValue("fixKind", out var k)
+ && !string.IsNullOrEmpty(k)
+ && k != "None");
+
+ var fixProvider = shape.ToFixProvider();
+ var actions = new List();
+ var context = new CodeFixContext(
+ document,
+ first,
+ (action, _) => actions.Add(action),
+ CancellationToken.None);
+ await fixProvider.RegisterCodeFixesAsync(context).ConfigureAwait(false);
+
+ actions.Count.ShouldBeGreaterThan(0);
+
+ var operations = await actions[0].GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
+ var applied = operations.OfType().First();
+ var newDoc = applied.ChangedSolution.GetDocument(document.Id)!;
+ var text = await newDoc.GetTextAsync().ConfigureAwait(false);
+ return text.ToString();
+ }
+}
diff --git a/Scribe/Scribe.csproj b/Scribe/Scribe.csproj
index 517f1f2..66bf236 100644
--- a/Scribe/Scribe.csproj
+++ b/Scribe/Scribe.csproj
@@ -1,23 +1,13 @@
-
+
- netstandard2.0
- 14
- true
- false
+ Library
-
- false
- embedded
-
- $(NoWarn);NU5128
-
- false
+ false
+ enable
BulletsForHumanity.Scribe
Fluent source-generation utilities for Roslyn incremental generators. Provides Quill (fluent source builder), naming helpers, XML doc extraction, and well-known BCL type constants.
@@ -30,14 +20,6 @@
-
-
-
-
-
diff --git a/Scribe/Shapes/FixSpec.cs b/Scribe/Shapes/FixSpec.cs
index 2c8e20e..fe29622 100644
--- a/Scribe/Shapes/FixSpec.cs
+++ b/Scribe/Shapes/FixSpec.cs
@@ -46,4 +46,22 @@ public enum FixKind
/// Add a base class to the type declaration (fix property baseClass).
AddBaseClass,
+
+ /// Remove the partial modifier from the type declaration.
+ RemovePartialModifier,
+
+ /// Remove the sealed modifier from the type declaration.
+ RemoveSealedModifier,
+
+ /// Remove the static modifier from the type declaration.
+ RemoveStaticModifier,
+
+ /// Remove an attribute from the declaration (fix property attribute).
+ RemoveAttribute,
+
+ /// Change the type's visibility modifier (fix property visibility — public/internal/private).
+ SetVisibility,
+
+ /// Add an explicit public parameterless constructor to the type.
+ AddParameterlessConstructor,
}
diff --git a/Scribe/Shapes/ShapeBuilder.cs b/Scribe/Shapes/ShapeBuilder.cs
index f388bc4..5a0ae2d 100644
--- a/Scribe/Shapes/ShapeBuilder.cs
+++ b/Scribe/Shapes/ShapeBuilder.cs
@@ -403,6 +403,389 @@ public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec =
return this;
}
+ // ───────────────────────────────────────────────────────────────
+ // Phase 8.5 — negations of positive primitives
+ // ───────────────────────────────────────────────────────────────
+
+ /// Forbid the partial modifier on the type.
+ public ShapeBuilder MustNotBePartial(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE013",
+ defaultTitle: "Type must not be partial",
+ defaultMessage: "Type '{0}' must not be declared 'partial'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.RemovePartialModifier,
+ predicate: static (sym, _, ct) => !IsPartial(sym, ct),
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the sealed modifier on the type.
+ public ShapeBuilder MustNotBeSealed(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE014",
+ defaultTitle: "Type must not be sealed",
+ defaultMessage: "Type '{0}' must not be declared 'sealed'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.RemoveSealedModifier,
+ // Value types and static classes are implicitly sealed — ignore them
+ // so this check only flags explicit 'sealed' on classes that could be unsealed.
+ predicate: static (sym, _, _) => !sym.IsSealed || sym.IsValueType || sym.IsStatic,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid attribute on the type.
+ public ShapeBuilder 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)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ AddCheck(
+ defaultId: "SCRIBE015",
+ defaultTitle: "Type must not carry forbidden attribute",
+ defaultMessage: "Type '{0}' must not be annotated with '[{1}]'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.AttributeList,
+ defaultFix: FixKind.RemoveAttribute,
+ predicate: (sym, _, _) => !HasAttribute(sym, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("attribute", interned));
+ return this;
+ }
+
+ ///
+ /// 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)
+ {
+ if (string.IsNullOrEmpty(pattern))
+ {
+ throw new ArgumentException("Pattern must not be empty.", nameof(pattern));
+ }
+
+ var regex = new Regex(pattern, RegexOptions.CultureInvariant);
+ AddCheck(
+ defaultId: "SCRIBE016",
+ defaultTitle: "Type name must not match forbidden pattern",
+ defaultMessage: "Type '{0}' name matches forbidden pattern '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.None,
+ predicate: (sym, _, _) => !regex.IsMatch(sym.Name),
+ messageArgs: sym => EquatableArray.Create(sym.Name, pattern),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the static modifier on the type.
+ public ShapeBuilder MustNotBeStatic(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE017",
+ defaultTitle: "Type must not be static",
+ defaultMessage: "Type '{0}' must not be declared 'static'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.RemoveStaticModifier,
+ predicate: static (sym, _, _) => !sym.IsStatic,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the type from extending .
+ public ShapeBuilder 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)
+ {
+ if (string.IsNullOrEmpty(metadataName))
+ {
+ throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName));
+ }
+
+ var interned = InternPool.Intern(metadataName);
+ AddCheck(
+ defaultId: "SCRIBE018",
+ defaultTitle: "Type must not extend forbidden base class",
+ defaultMessage: "Type '{0}' must not extend base class '{1}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.BaseList,
+ defaultFix: FixKind.RemoveFromBaseList,
+ predicate: (sym, compilation, _) => !ExtendsByMetadataName(sym, compilation, interned),
+ messageArgs: sym => EquatableArray.Create(sym.Name, interned),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("baseClass", interned));
+ return this;
+ }
+
+ ///
+ /// 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)
+ {
+ if (string.IsNullOrEmpty(pattern))
+ {
+ throw new ArgumentException("Pattern must not be empty.", nameof(pattern));
+ }
+
+ var regex = new Regex(pattern, RegexOptions.CultureInvariant);
+ AddCheck(
+ defaultId: "SCRIBE019",
+ defaultTitle: "Type must not be in forbidden namespace",
+ defaultMessage: "Type '{0}' is in namespace '{1}' which matches forbidden pattern '{2}'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ContainingNamespace,
+ defaultFix: FixKind.None,
+ predicate: (sym, _, _) => !regex.IsMatch(sym.ContainingNamespace?.ToDisplayString() ?? string.Empty),
+ messageArgs: sym => EquatableArray.Create(
+ sym.Name,
+ sym.ContainingNamespace?.ToDisplayString() ?? string.Empty,
+ pattern),
+ spec: spec);
+ return this;
+ }
+
+ /// Require the type to declare at least one generic type parameter.
+ public ShapeBuilder MustBeGeneric(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE020",
+ defaultTitle: "Type must be generic",
+ defaultMessage: "Type '{0}' must declare at least one generic type parameter",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.IsGenericType,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Phase 8.5 — visibility
+ // ───────────────────────────────────────────────────────────────
+
+ /// Require the type to be declared public.
+ public ShapeBuilder MustBePublic(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE021",
+ defaultTitle: "Type must be public",
+ defaultMessage: "Type '{0}' must be declared 'public'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.SetVisibility,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Public,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("visibility", "public"));
+ return this;
+ }
+
+ /// Require the type to be declared internal.
+ public ShapeBuilder MustBeInternal(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE022",
+ defaultTitle: "Type must be internal",
+ defaultMessage: "Type '{0}' must be declared 'internal'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.SetVisibility,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Internal,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("visibility", "internal"));
+ return this;
+ }
+
+ ///
+ /// Require a nested type to be declared private. Top-level types cannot be
+ /// private — use for that case.
+ ///
+ public ShapeBuilder MustBePrivate(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE023",
+ defaultTitle: "Type must be private",
+ defaultMessage: "Type '{0}' must be declared 'private'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.SetVisibility,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Private,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec,
+ fixProperties: _ => ImmutableDictionary.Empty
+ .Add("visibility", "private"));
+ return this;
+ }
+
+ /// Forbid the public visibility modifier on the type.
+ public ShapeBuilder MustNotBePublic(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE024",
+ defaultTitle: "Type must not be public",
+ defaultMessage: "Type '{0}' must not be declared 'public'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Public,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the internal visibility modifier on the type.
+ public ShapeBuilder MustNotBeInternal(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE025",
+ defaultTitle: "Type must not be internal",
+ defaultMessage: "Type '{0}' must not be declared 'internal'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Internal,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the private visibility modifier on the type.
+ public ShapeBuilder MustNotBePrivate(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE026",
+ defaultTitle: "Type must not be private",
+ defaultMessage: "Type '{0}' must not be declared 'private'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.ModifierList,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Private,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Phase 8.5 — declaration kind
+ // ───────────────────────────────────────────────────────────────
+
+ /// Require the type to be a record (class-record or record-struct).
+ public ShapeBuilder MustBeRecord(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE027",
+ defaultTitle: "Type must be a record",
+ defaultMessage: "Type '{0}' must be declared as a 'record'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.IsRecord,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the record keyword on the type.
+ public ShapeBuilder MustNotBeRecord(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE028",
+ defaultTitle: "Type must not be a record",
+ defaultMessage: "Type '{0}' must not be declared as a 'record'",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => !sym.IsRecord,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Require the type to be a value type (struct or record struct).
+ public ShapeBuilder MustBeValueType(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE029",
+ defaultTitle: "Type must be a value type",
+ defaultMessage: "Type '{0}' must be a value type",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => sym.IsValueType,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ /// Forbid the type from being a value type.
+ public ShapeBuilder MustNotBeValueType(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE030",
+ defaultTitle: "Type must not be a value type",
+ defaultMessage: "Type '{0}' must not be a value type",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.TypeKeyword,
+ defaultFix: FixKind.None,
+ predicate: static (sym, _, _) => !sym.IsValueType,
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Phase 8.5 — constructors
+ // ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Require a public parameterless constructor. Satisfied by the implicit
+ /// default constructor on classes with no other instance constructors; for
+ /// value types the default ctor is always present. Primary constructors on
+ /// records and structs are not considered parameterless.
+ ///
+ public ShapeBuilder MustHaveParameterlessConstructor(DiagnosticSpec? spec = null)
+ {
+ AddCheck(
+ defaultId: "SCRIBE031",
+ defaultTitle: "Type must have a public parameterless constructor",
+ defaultMessage: "Type '{0}' must declare a public parameterless constructor",
+ defaultSeverity: DiagnosticSeverity.Error,
+ defaultSquiggle: SquiggleAt.Identifier,
+ defaultFix: FixKind.AddParameterlessConstructor,
+ predicate: static (sym, _, _) => HasPublicParameterlessCtor(sym),
+ messageArgs: static sym => EquatableArray.Create(sym.Name),
+ spec: spec);
+ return this;
+ }
+
// ───────────────────────────────────────────────────────────────
// Predicate helpers (static, closure-free)
// ───────────────────────────────────────────────────────────────
@@ -468,6 +851,34 @@ private static bool ExtendsByMetadataName(
return false;
}
+ private static bool HasPublicParameterlessCtor(INamedTypeSymbol symbol)
+ {
+ // Value types always have a default parameterless constructor.
+ if (symbol.IsValueType)
+ {
+ return true;
+ }
+
+ // Static classes never need a constructor.
+ if (symbol.IsStatic)
+ {
+ return true;
+ }
+
+ // Roslyn surfaces the compiler-synthesised public default constructor in
+ // InstanceConstructors, so a single check covers implicit + explicit ctors.
+ foreach (var ctor in symbol.InstanceConstructors)
+ {
+ if (ctor.Parameters.Length == 0
+ && ctor.DeclaredAccessibility == Accessibility.Public)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName)
{
foreach (var attribute in symbol.GetAttributes())
diff --git a/Scribe/Stubs.cs b/Scribe/Stubs.cs
deleted file mode 100644
index 7f80565..0000000
--- a/Scribe/Stubs.cs
+++ /dev/null
@@ -1,266 +0,0 @@
-// Polyfill stubs for modern C# language features on netstandard2.0.
-//
-// These types are recognised by the compiler by their well-known fully-qualified names.
-// Marking them internal prevents conflicts with the real BCL types when the binary is
-// loaded in a .NET 5+ host (which already carries the real definitions).
-//
-// Feature map:
-// C# 9 init setters / record types → IsExternalInit
-// C# 9 [ModuleInitializer] → ModuleInitializerAttribute
-// C# 9 [SkipLocalsInit] → SkipLocalsInitAttribute
-// C# 10 CallerArgumentExpression → CallerArgumentExpressionAttribute
-// C# 10 Interpolated string handlers → InterpolatedStringHandlerAttribute
-// InterpolatedStringHandlerArgumentAttribute
-// C# 11 required members → RequiredMemberAttribute
-// CompilerFeatureRequiredAttribute
-// C# 11 scoped ref parameters → ScopedRefAttribute
-// Nullable flow analysis attributes → System.Diagnostics.
-
-// ── System.Runtime.CompilerServices ──────────────────────────────────────────
-
-#if !NET5_0_OR_GREATER
-#pragma warning disable IDE0130 // Namespace does not match folder structure
-namespace System.Runtime.CompilerServices
-#pragma warning restore IDE0130 // Namespace does not match folder structure
-{
- ///
- /// Marks the init accessor and is required for record types.
- /// The compiler looks for this class by its fully-qualified name.
- ///
- internal static class IsExternalInit { }
-
- /// Enables the required modifier on members (C# 11).
- [AttributeUsage(
- AttributeTargets.Class
- | AttributeTargets.Struct
- | AttributeTargets.Field
- | AttributeTargets.Property,
- Inherited = false,
- AllowMultiple = false
- )]
- internal sealed class RequiredMemberAttribute : Attribute { }
-
- ///
- /// Companion to . The compiler emits this on
- /// every constructor of a type that has required members (C# 11).
- ///
- [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
- internal sealed class CompilerFeatureRequiredAttribute : Attribute
- {
- public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName;
-
- public string FeatureName { get; }
-
- ///
- /// When , a compiler that does not understand this feature
- /// is permitted to ignore it. When (the default), the
- /// compiler must reject the construct.
- ///
- public bool IsOptional { get; init; }
-
- public const string RefStructs = nameof(RefStructs);
- public const string RequiredMembers = nameof(RequiredMembers);
- }
-
- /// Enables [ModuleInitializer] on a static void method (C# 9).
- [AttributeUsage(AttributeTargets.Method, Inherited = false)]
- internal sealed class ModuleInitializerAttribute : Attribute { }
-
- ///
- /// Suppresses zero-initialisation of locals in the decorated method or type (C# 9).
- ///
- [AttributeUsage(
- AttributeTargets.Module
- | AttributeTargets.Class
- | AttributeTargets.Struct
- | AttributeTargets.Interface
- | AttributeTargets.Constructor
- | AttributeTargets.Method
- | AttributeTargets.Property
- | AttributeTargets.Event,
- Inherited = false
- )]
- internal sealed class SkipLocalsInitAttribute : Attribute { }
-
- ///
- /// Captures the source-text of the argument passed to a decorated parameter (C# 10).
- ///
- [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
- internal sealed class CallerArgumentExpressionAttribute : Attribute
- {
- public CallerArgumentExpressionAttribute(string parameterName) =>
- ParameterName = parameterName;
-
- public string ParameterName { get; }
- }
-
- /// Marks a type as a custom interpolated string handler (C# 10).
- [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
- internal sealed class InterpolatedStringHandlerAttribute : Attribute { }
-
- ///
- /// Specifies which arguments of a method call are passed to a custom interpolated
- /// string handler (C# 10).
- ///
- [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
- internal sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
- {
- public InterpolatedStringHandlerArgumentAttribute(string argument) =>
- Arguments = [argument];
-
- public InterpolatedStringHandlerArgumentAttribute(params string[] arguments) =>
- Arguments = arguments;
-
- public string[] Arguments { get; }
- }
-
- ///
- /// Indicates that a parameter is scoped — its ref-safety scope does not
- /// extend beyond the method boundary (C# 11).
- ///
- [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
- internal sealed class ScopedRefAttribute : Attribute { }
-}
-
-// ── System.Diagnostics.CodeAnalysis — nullable flow analysis ─────────────────
-
-namespace System.Diagnostics.CodeAnalysis
-{
- ///
- /// Specifies that an output will not be even if the
- /// corresponding type allows it.
- ///
- [AttributeUsage(
- AttributeTargets.Field
- | AttributeTargets.Parameter
- | AttributeTargets.Property
- | AttributeTargets.ReturnValue,
- Inherited = false
- )]
- internal sealed class NotNullAttribute : Attribute { }
-
- ///
- /// Specifies that an output may be even if the
- /// corresponding type does not allow it.
- ///
- [AttributeUsage(
- AttributeTargets.Field
- | AttributeTargets.Parameter
- | AttributeTargets.Property
- | AttributeTargets.ReturnValue,
- Inherited = false
- )]
- internal sealed class MaybeNullAttribute : Attribute { }
-
- /// Specifies that is allowed as an input even if the type does not allow it.
- [AttributeUsage(
- AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property,
- Inherited = false
- )]
- internal sealed class AllowNullAttribute : Attribute { }
-
- /// Specifies that is disallowed as an input even if the type allows it.
- [AttributeUsage(
- AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property,
- Inherited = false
- )]
- internal sealed class DisallowNullAttribute : Attribute { }
-
- ///
- /// Specifies that when the method returns , the
- /// decorated parameter will not be .
- ///
- [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
- internal sealed class NotNullWhenAttribute : Attribute
- {
- public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
-
- public bool ReturnValue { get; }
- }
-
- ///
- /// Specifies that when the method returns , the
- /// decorated parameter or return value may be .
- ///
- [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)]
- internal sealed class MaybeNullWhenAttribute : Attribute
- {
- public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
-
- public bool ReturnValue { get; }
- }
-
- ///
- /// Specifies that the return value of the decorated member is non-null when the
- /// named parameter is non-null.
- ///
- [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)]
- internal sealed class NotNullIfNotNullAttribute : Attribute
- {
- public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName;
-
- public string ParameterName { get; }
- }
-
- /// Applied to a method that will never return under any circumstance.
- [AttributeUsage(AttributeTargets.Method, Inherited = false)]
- internal sealed class DoesNotReturnAttribute : Attribute { }
-
- ///
- /// Specifies that the method will not return if the decorated boolean parameter
- /// has the specified value.
- ///
- [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
- internal sealed class DoesNotReturnIfAttribute : Attribute
- {
- public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue;
-
- public bool ParameterValue { get; }
- }
-
- ///
- /// Specifies that the method or property ensures that the listed members are not
- /// .
- ///
- [AttributeUsage(
- AttributeTargets.Method | AttributeTargets.Property,
- Inherited = false,
- AllowMultiple = true
- )]
- internal sealed class MemberNotNullAttribute : Attribute
- {
- public MemberNotNullAttribute(string member) => Members = [member];
-
- public MemberNotNullAttribute(params string[] members) => Members = members;
-
- public string[] Members { get; }
- }
-
- ///
- /// Specifies that the method or property ensures that the listed members are not
- /// when returning the specified value.
- ///
- [AttributeUsage(
- AttributeTargets.Method | AttributeTargets.Property,
- Inherited = false,
- AllowMultiple = true
- )]
- internal sealed class MemberNotNullWhenAttribute : Attribute
- {
- public MemberNotNullWhenAttribute(bool returnValue, string member)
- {
- ReturnValue = returnValue;
- Members = [member];
- }
-
- public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
- {
- ReturnValue = returnValue;
- Members = members;
- }
-
- public bool ReturnValue { get; }
- public string[] Members { get; }
- }
-}
-#endif
diff --git a/Scribe/build/BulletsForHumanity.Scribe.props b/Scribe/build/BulletsForHumanity.Scribe.props
deleted file mode 100644
index b309b80..0000000
--- a/Scribe/build/BulletsForHumanity.Scribe.props
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/Scribe/build/BulletsForHumanity.Scribe.targets b/Scribe/build/BulletsForHumanity.Scribe.targets
deleted file mode 100644
index 8b56345..0000000
--- a/Scribe/build/BulletsForHumanity.Scribe.targets
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/docs/project-setup.md b/docs/project-setup.md
index b8fb6af..f280633 100644
--- a/docs/project-setup.md
+++ b/docs/project-setup.md
@@ -73,6 +73,8 @@ If you prefer not to use the SDK, configure your project manually.
Roslyn analyzers and source generators must target **netstandard2.0** — this is a hard requirement from the compiler host.
+> **Note:** `Scribe.csproj` itself stays on `Microsoft.NET.Sdk` because it *produces* the Scribe runtime DLL — the Sdk cannot consume the package it ships. This is the one project in the Scribe repo that deliberately diverges from the SDK pattern; all other analyzer/generator projects in the repo (and every consumer downstream) should use `BulletsForHumanity.Scribe.Sdk`.
+
### Minimal .csproj
```xml
diff --git a/global.json b/global.json
index 2f153fa..802692c 100644
--- a/global.json
+++ b/global.json
@@ -3,5 +3,8 @@
"version": "10.0.200",
"rollForward": "patch",
"allowPrerelease": false
+ },
+ "msbuild-sdks": {
+ "BulletsForHumanity.Scribe.Sdk": "0.5.1"
}
}