Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Scribe LocalDev sentinel
.localscribe
# Scribe LocalDev sentinels — per-producer (.scribe.user, .hermetic.local, ...)
# Either suffix activates producer mode; both are supported (see Scribe docs).
*.user
*.local

# Build output
bin/
Expand Down
19 changes: 11 additions & 8 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<!-- CA1707: test method names use underscores by convention. -->
<NoWarn>$(NoWarn);CA1707</NoWarn>
<!-- CA1707: test method names use underscores by convention.
RS2008: release tracking applies to analyzer projects, not test projects that
happen to construct DiagnosticDescriptor instances for assertions. -->
<NoWarn>$(NoWarn);CA1707;RS2008</NoWarn>
</PropertyGroup>

<!-- ── Scribe LocalDev ─────────────────────────────────────────────── -->
<!-- Scribe can't get these from a NuGet package (it IS the package),
so we set them here and import the props file directly. -->
so we set them here and import the props file directly.
$(ScribesName) drives sentinel detection (.scribe.user or .scribe.local),
auto-pack, and override-file naming. -->
<PropertyGroup>
<ScribeRoot>$(MSBuildThisFileDirectory)..</ScribeRoot>
<ScribeLocalDevTriggerProject>Scribe</ScribeLocalDevTriggerProject>
<ScribeLocalDevPackageNames>BulletsForHumanity.Scribe</ScribeLocalDevPackageNames>
<ScribesName>Scribe</ScribesName>
</PropertyGroup>
<Import Project="$(MSBuildThisFileDirectory)Scribe\build\Scribe.LocalDev.props"
Condition="Exists('$(MSBuildThisFileDirectory)Scribe\build\Scribe.LocalDev.props')" />
Expand Down Expand Up @@ -63,9 +66,9 @@
============================================================ -->
<PropertyGroup>
<!-- CI uses explicit `dotnet pack` in the pipeline. No auto-pack needed.
In Local Scribe mode, Scribe.LocalDev.props already redirects the trigger project
to the shared artifacts directory — don't overwrite that. -->
<PackageOutputPath Condition="'$(IsLocalScribe)' != 'true'">$(ArtifactsPath)packages/</PackageOutputPath>
When this producer is in local mode, Scribe.LocalDev.props redirects the
trigger project to the shared artifacts directory — don't overwrite that. -->
<PackageOutputPath Condition="'$(IsLocalProducer)' != 'true'">$(ArtifactsPath)packages/</PackageOutputPath>
<Authors>Max Obrist</Authors>
<Company>Bullets for Humanity</Company>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Name="_ScribeLocalDevTimestampVersion"
AfterTargets="GetBuildVersion"
BeforeTargets="Build;Pack;GenerateNuspec"
Condition="'$(IsLocalScribe)' == 'true' and '$(IsPackable)' != 'false'"
Condition="'$(IsLocalProducer)' == 'true' and '$(IsPackable)' != 'false'"
>
<PropertyGroup>
<_DevTimestamp>$([System.DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss-fff"))</_DevTimestamp>
Expand Down
46 changes: 46 additions & 0 deletions Scribe.Ink/Scribe.Ink.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!--
TODO(dogfood): switch to <Project Sdk="BulletsForHumanity.Scribe.Sdk"> once the
Sdk's project-types support lands (see SCRIBE_SDK_PROJECT_TYPES_REQS.md).
Until then, use Microsoft.NET.Sdk with the same settings Scribe.Sdk's Sdk.props applies.
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>14</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageType>Analyzer</PackageType>
<IncludeSymbols>false</IncludeSymbols>
<DebugType>embedded</DebugType>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<!--
RS1038: Workspaces-referencing assembly hosting an analyzer. We split usage
by class (analyzers don't touch Workspaces types; fixers do), so the Roslyn
host loads both happily. The blanket rule is conservative, so suppress it.
RS2008: analyzer release tracking is overkill for a small, versioned package.
-->
<NoWarn>$(NoWarn);NU5128;RS1038;RS2008</NoWarn>
<PackageId>BulletsForHumanity.Scribe.Ink</PackageId>
<Description>Roslyn analyzers, code fix providers, and source generators for Scribe shapes. The scribe dips the quill in ink to mark, correct, and emend.</Description>
<PackageTags>roslyn;analyzer;code-fix;source-generator;scribe</PackageTags>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Scribe.Tests" />
</ItemGroup>

<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="" />
<None Include="..\README.md" Pack="true" PackagePath="" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Scribe\Scribe.csproj" />
</ItemGroup>
</Project>
169 changes: 169 additions & 0 deletions Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Scribe.Ink.Shapes;

/// <summary>
/// SCRIBE101 — forbid Roslyn reference types on any TModel passed to
/// <c>Shape&lt;TModel&gt;.Project&lt;TModel&gt;</c>. Holding an <c>ISymbol</c>,
/// <c>SyntaxNode</c>, <c>Compilation</c>, <c>SemanticModel</c>, <c>SyntaxTree</c>,
/// <c>Location</c>, or <c>AttributeData</c> in a cached model defeats the
/// incremental generator cache: those objects identity-compare per compilation.
/// Extract the primitive data you need into strings, equatable arrays, or
/// <see cref="Scribe.Cache.LocationInfo"/> instead.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class CacheCorrectnessAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "SCRIBE101";

private static readonly DiagnosticDescriptor Descriptor = new(
id: DiagnosticId,
title: "Cache-hostile type in Shape projection model",
messageFormat: "Member '{0}' of type '{1}' is a Roslyn reference type ('{2}') — storing it in a Shape projection model defeats incremental caching. Extract primitives or use LocationInfo instead.",
category: "Scribe.Cache",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
= ImmutableArray.Create(Descriptor);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(OnCompilationStart);
}

private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
var shapeBuilder = context.Compilation.GetTypeByMetadataName("Scribe.Shapes.ShapeBuilder");
if (shapeBuilder is null)
{
return;
}

var forbidden = CollectForbiddenTypes(context.Compilation);
if (forbidden.IsEmpty)
{
return;
}

context.RegisterOperationAction(
ctx => AnalyzeInvocation(ctx, shapeBuilder, forbidden),
OperationKind.Invocation);
}

private static ImmutableArray<INamedTypeSymbol> CollectForbiddenTypes(Compilation compilation)
{
var builder = ImmutableArray.CreateBuilder<INamedTypeSymbol>();
void Add(string metadataName)
{
var t = compilation.GetTypeByMetadataName(metadataName);
if (t is not null)
{
builder.Add(t);
}
}

Add("Microsoft.CodeAnalysis.ISymbol");
Add("Microsoft.CodeAnalysis.SyntaxNode");
Add("Microsoft.CodeAnalysis.SyntaxTree");
Add("Microsoft.CodeAnalysis.SemanticModel");
Add("Microsoft.CodeAnalysis.Compilation");
Add("Microsoft.CodeAnalysis.Location");
Add("Microsoft.CodeAnalysis.AttributeData");

return builder.ToImmutable();
}

private static void AnalyzeInvocation(
OperationAnalysisContext context,
INamedTypeSymbol shapeBuilder,
ImmutableArray<INamedTypeSymbol> forbidden)
{
if (context.Operation is not IInvocationOperation invocation)
{
return;
}

var method = invocation.TargetMethod;
if (method.Name != "Project"
|| method.TypeArguments.Length != 1
|| !SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, shapeBuilder))
{
return;
}

if (method.TypeArguments[0] is not INamedTypeSymbol model)
{
return;
}

foreach (var member in model.GetMembers())
{
if (member.IsStatic)
{
continue;
}

var memberType = member switch
{
IFieldSymbol f when !f.IsConst && !f.IsImplicitlyDeclared => f.Type,
IPropertySymbol p when !p.IsIndexer => p.Type,
_ => null,
};

if (memberType is null)
{
continue;
}

var offender = FindForbidden(memberType, forbidden);
if (offender is null)
{
continue;
}

var location = member.Locations.Length > 0 ? member.Locations[0] : invocation.Syntax.GetLocation();
context.ReportDiagnostic(Diagnostic.Create(
Descriptor,
location,
member.Name,
model.Name,
offender.ToDisplayString()));
}
}

private static ITypeSymbol? FindForbidden(ITypeSymbol type, ImmutableArray<INamedTypeSymbol> forbidden)
{
var current = type;
while (current is not null)
{
foreach (var banned in forbidden)
{
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, banned))
{
return banned;
}
}

foreach (var iface in current.AllInterfaces)
{
foreach (var banned in forbidden)
{
if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, banned))
{
return banned;
}
}
}

current = current.BaseType;
}

return null;
}
}
47 changes: 47 additions & 0 deletions Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Scribe.Ink.Shapes.Fixes;

internal sealed class AddAbstractModifierFix : IShapeFix
{
public string Title(Diagnostic _) => "Add 'abstract' modifier";

public async Task<Document> FixAsync(
Document document,
TypeDeclarationSyntax typeDecl,
Diagnostic _,
CancellationToken ct)
{
if (typeDecl.Modifiers.Any(SyntaxKind.AbstractKeyword))
{
return document;
}

var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
if (root is null)
{
return document;
}

var abstractToken = SyntaxFactory.Token(SyntaxKind.AbstractKeyword)
.WithTrailingTrivia(SyntaxFactory.Space);

var modifiers = typeDecl.Modifiers;
var insertAt = modifiers.Count;
for (var i = 0; i < modifiers.Count; i++)
{
if (modifiers[i].IsKind(SyntaxKind.PartialKeyword))
{
insertAt = i;
break;
}
}

var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, abstractToken));
return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
}
}
57 changes: 57 additions & 0 deletions Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Scribe.Ink.Shapes.Fixes;

internal sealed class AddAttributeFix : IShapeFix
{
public string Title(Diagnostic diagnostic)
{
var name = AttributeName(diagnostic);
return name is null ? "Add required attribute" : $"Add '[{Shorten(name)}]'";
}

public async Task<Document> FixAsync(
Document document,
TypeDeclarationSyntax typeDecl,
Diagnostic diagnostic,
CancellationToken ct)
{
var attributeName = AttributeName(diagnostic);
if (attributeName is null)
{
return document;
}

var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
if (root is null)
{
return document;
}

var attr = SyntaxFactory.Attribute(SyntaxFactory.ParseName(Shorten(attributeName)));
var attrList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attr));

var newTypeDecl = typeDecl.WithAttributeLists(typeDecl.AttributeLists.Add(attrList));
return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl));
}

private static string? AttributeName(Diagnostic diagnostic)
{
return diagnostic.Properties.TryGetValue("attribute", out var name) && !string.IsNullOrEmpty(name)
? name
: null;
}

// Strip trailing "Attribute" for the bracket form; keep full name otherwise.
private static string Shorten(string name)
{
const string Suffix = "Attribute";
return name.EndsWith(Suffix, System.StringComparison.Ordinal)
? name.Substring(0, name.Length - Suffix.Length)
: name;
}
}
Loading
Loading