diff --git a/README.md b/README.md
index 9737acd..377ac19 100644
--- a/README.md
+++ b/README.md
@@ -61,3 +61,10 @@ int SuitsToInt(CardSuit suit) => switch {
https://www.nuget.org/packages/StaticCS.Async
A library for structured concurrency in C#. See [the Async README.md](src/Async/README.md) for more info.
+
+### StaticCS.CsSig
+
+An analyzer that pins a project's public API surface using `.cssig` files — ordinary C# member
+declarations with no bodies. The analyzer enforces that the project's public API exactly matches the
+declared signatures, in both directions (like the Roslyn Public API analyzer, but using real C#
+instead of a flat text format). See [the CsSig README.md](src/CsSig/README.md) for more info.
diff --git a/src/CsSig/Analyzer/ApiSurface.cs b/src/CsSig/Analyzer/ApiSurface.cs
new file mode 100644
index 0000000..5ad5d97
--- /dev/null
+++ b/src/CsSig/Analyzer/ApiSurface.cs
@@ -0,0 +1,287 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace CsSig;
+
+///
+/// Determines a compilation's public API surface and reduces each member to a structural
+/// signature, so that symbols originating from two different compilations
+/// (the project and the synthetic .cssig compilation) can be compared for equivalence.
+///
+/// The surface-detection rules and the display format are ported from the Roslyn Public API
+/// analyzer (dotnet/roslyn:
+/// src/RoslynAnalyzers/PublicApiAnalyzers/Core/Analyzers/DeclarePublicApiAnalyzer*.cs).
+///
+internal static class ApiSurface
+{
+ private const string InstanceConstructorName = ".ctor";
+
+ ///
+ /// A readable signature format, used only for diagnostic messages. Equivalence is decided by
+ /// the structural , not by this string.
+ ///
+ private static readonly SymbolDisplayFormat s_displayFormat =
+ new(
+ globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining,
+ typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
+ propertyStyle: SymbolDisplayPropertyStyle.ShowReadWriteDescriptor,
+ genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
+ memberOptions:
+ SymbolDisplayMemberOptions.IncludeParameters |
+ SymbolDisplayMemberOptions.IncludeContainingType |
+ SymbolDisplayMemberOptions.IncludeExplicitInterface |
+ SymbolDisplayMemberOptions.IncludeModifiers |
+ SymbolDisplayMemberOptions.IncludeConstantValue,
+ parameterOptions:
+ SymbolDisplayParameterOptions.IncludeExtensionThis |
+ SymbolDisplayParameterOptions.IncludeParamsRefOut |
+ SymbolDisplayParameterOptions.IncludeType |
+ SymbolDisplayParameterOptions.IncludeName |
+ SymbolDisplayParameterOptions.IncludeDefaultValue,
+ miscellaneousOptions:
+ SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
+
+ ///
+ /// Builds a map from the structural signature of every externally visible member declared in
+ /// to its source location and a human-readable display string.
+ ///
+ public static Dictionary Collect(IAssemblySymbol assembly)
+ {
+ var map = new Dictionary();
+
+ foreach (var type in AllNamedTypes(assembly.GlobalNamespace))
+ {
+ if (!IsTrackedApi(type))
+ {
+ continue;
+ }
+
+ Add(map, type);
+
+ foreach (var member in GetApiMembers(type))
+ {
+ Add(map, member);
+ }
+ }
+
+ return map;
+ }
+
+ private static void Add(Dictionary map, ISymbol symbol)
+ {
+ var member = ApiMember.From(symbol);
+ if (map.ContainsKey(member.Identity))
+ {
+ return;
+ }
+
+ map.Add(member.Identity, new ApiEntry(member, GetLocation(symbol), GetDisplay(symbol)));
+ }
+
+ private static Location GetLocation(ISymbol symbol)
+ {
+ var location = symbol.Locations.FirstOrDefault(static l => l.IsInSource);
+ if (location is null && symbol.ContainingType is { } containingType)
+ {
+ location = containingType.Locations.FirstOrDefault(static l => l.IsInSource);
+ }
+
+ return location ?? Location.None;
+ }
+
+ /// Yields the tracked members of , including implicit
+ /// constructors and implicit record members, mirroring the Public API analyzer.
+ private static IEnumerable GetApiMembers(INamedTypeSymbol type)
+ {
+ foreach (var member in type.GetMembers())
+ {
+ // Nested types are visited independently by AllNamedTypes.
+ if (member is INamedTypeSymbol)
+ {
+ continue;
+ }
+
+ if (member.IsImplicitlyDeclared)
+ {
+ continue;
+ }
+
+ if (IsTrackedApi(member))
+ {
+ yield return member;
+ }
+ }
+
+ // Implicitly declared (parameterless) constructor.
+ IMethodSymbol? implicitConstructor = null;
+ if (type is { TypeKind: TypeKind.Class, InstanceConstructors.Length: 1 } or { TypeKind: TypeKind.Struct })
+ {
+ implicitConstructor = type.InstanceConstructors.FirstOrDefault(static c => c.IsImplicitlyDeclared);
+ if (implicitConstructor is not null && IsTrackedApi(implicitConstructor))
+ {
+ yield return implicitConstructor;
+ }
+ }
+
+ // Implicitly declared members of a record (Equals, GetHashCode, Deconstruct, copy ctor,
+ // positional property accessors, ...).
+ foreach (var member in type.GetMembers())
+ {
+ if (SymbolEqualityComparer.Default.Equals(member, implicitConstructor))
+ {
+ continue;
+ }
+
+ if (member is IMethodSymbol { IsImplicitlyDeclared: true } method && IsTrackedApi(method))
+ {
+ // Skip accessors of explicit (non-implicit) properties: those properties are
+ // tracked through their own accessor callbacks already. Keep accessors that
+ // belong to implicit properties (e.g. record `EqualityContract`).
+ if (method.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet) ||
+ method is { AssociatedSymbol.IsImplicitlyDeclared: true })
+ {
+ yield return method;
+ }
+ }
+ }
+ }
+
+ private static IEnumerable AllNamedTypes(INamespaceSymbol root)
+ {
+ var stack = new Stack();
+ stack.Push(root);
+
+ while (stack.Count > 0)
+ {
+ var current = stack.Pop();
+ foreach (var member in current.GetMembers())
+ {
+ switch (member)
+ {
+ case INamespaceSymbol ns:
+ stack.Push(ns);
+ break;
+ case INamedTypeSymbol type:
+ yield return type;
+ stack.Push(type);
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Whether a symbol is part of the externally visible (public) API surface.
+ /// Ported from the Public API analyzer's IsTrackedAPI/IsTrackedApiCore.
+ ///
+ public static bool IsTrackedApi(ISymbol symbol)
+ {
+ if (symbol is IMethodSymbol methodSymbol)
+ {
+ // Event accessors are encoded via the event symbol itself.
+ if (methodSymbol.MethodKind is MethodKind.EventAdd or MethodKind.EventRemove)
+ {
+ return false;
+ }
+
+ // Enum constructors are not user-visible API.
+ if (methodSymbol is { MethodKind: MethodKind.Constructor, ContainingType.TypeKind: TypeKind.Enum })
+ {
+ return false;
+ }
+
+ // For delegates, only the 'Invoke' method carries the signature.
+ if (methodSymbol is { ContainingType.TypeKind: TypeKind.Delegate, MethodKind: not MethodKind.DelegateInvoke })
+ {
+ return false;
+ }
+ }
+
+ // Properties are not tracked directly; their get/set accessors (IMethodSymbols) are.
+ if (symbol is IPropertySymbol)
+ {
+ return false;
+ }
+
+ if (symbol.GetResultantVisibility() != SymbolVisibility.Public)
+ {
+ return false;
+ }
+
+ for (ISymbol? current = symbol; current is not null; current = current.ContainingType)
+ {
+ switch (current.DeclaredAccessibility)
+ {
+ case Accessibility.Protected:
+ case Accessibility.ProtectedOrInternal:
+ // Protected members are only externally visible if the containing type can
+ // actually be extended outside the assembly.
+ if (current.ContainingType is not { } container || !CanTypeBeExtended(container))
+ {
+ return false;
+ }
+
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool CanTypeBeExtended(ITypeSymbol type)
+ {
+ // A type can be extended publicly if it isn't sealed and has a constructor that is not
+ // internal, private, or protected-and-internal.
+ return !type.IsSealed &&
+ type.GetMembers(InstanceConstructorName).Any(static m => m.DeclaredAccessibility switch
+ {
+ Accessibility.Internal or Accessibility.ProtectedAndInternal => false,
+ Accessibility.Private => false,
+ _ => true,
+ });
+ }
+
+ ///
+ /// Produces a human-readable signature string for diagnostic messages only. Ported from the
+ /// Public API analyzer's getApiString (without the nullability/oblivious variants).
+ ///
+ private static string GetDisplay(ISymbol symbol)
+ {
+ var signature = symbol.ToDisplayString(s_displayFormat);
+
+ ITypeSymbol? memberType = symbol switch
+ {
+ IMethodSymbol method => method.ReturnType,
+ IPropertySymbol property => property.Type,
+ IEventSymbol @event => @event.Type,
+ IFieldSymbol field => field.Type,
+ _ => null,
+ };
+
+ if (memberType is not null)
+ {
+ signature = signature + " -> " + memberType.ToDisplayString(s_displayFormat);
+ }
+
+ return signature;
+ }
+}
+
+/// A located, human-readable record of a single API member.
+internal readonly struct ApiEntry
+{
+ public ApiEntry(ApiMember member, Location location, string display)
+ {
+ Member = member;
+ Location = location;
+ Display = display;
+ }
+
+ /// The structural model, used to compare source/binary equivalence.
+ public ApiMember Member { get; }
+
+ public Location Location { get; }
+
+ public string Display { get; }
+}
diff --git a/src/CsSig/Analyzer/CsSigAnalyzer.cs b/src/CsSig/Analyzer/CsSigAnalyzer.cs
new file mode 100644
index 0000000..cfc6d60
--- /dev/null
+++ b/src/CsSig/Analyzer/CsSigAnalyzer.cs
@@ -0,0 +1,234 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+
+namespace CsSig;
+
+///
+/// Checks that a project's public API surface exactly matches the C# signatures declared in its
+/// .cssig additional files. The approach mirrors the Roslyn Public API analyzer, but the
+/// source of truth is real (body-less) C# rather than a flat text format: the .cssig files
+/// are parsed into a synthetic compilation, symbols are produced from both it and the project, and
+/// the two sets are compared for equivalence via structural signatures.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class CsSigAnalyzer : DiagnosticAnalyzer
+{
+ internal const string Extension = ".cssig";
+
+ private static readonly DiagnosticDescriptor s_missingFromProject = new(
+ id: DiagId.MissingFromProject.ToIdString(),
+ title: "Signature is missing from the project",
+ messageFormat: "The signature '{0}' is declared in a .cssig file but is not part of the project's public API (breaks {1} equivalence)",
+ category: "CsSig",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor s_missingFromSignature = new(
+ id: DiagId.MissingFromSignature.ToIdString(),
+ title: "Public API is missing from the .cssig file",
+ messageFormat: "The signature '{0}' is part of the project's public API but is not declared in any .cssig file (breaks {1} equivalence)",
+ category: "CsSig",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor s_signatureFileError = new(
+ id: DiagId.SignatureFileError.ToIdString(),
+ title: "Invalid .cssig file",
+ messageFormat: "The .cssig file could not be parsed: {0}",
+ category: "CsSig",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor s_signatureMismatch = new(
+ id: DiagId.SignatureMismatch.ToIdString(),
+ title: "Signature does not match the project",
+ messageFormat: "The signature '{0}' is declared in a .cssig file but does not match the project's public API (breaks {1} equivalence)",
+ category: "CsSig",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(
+ s_missingFromProject, s_missingFromSignature, s_signatureFileError, s_signatureMismatch, CsSigRecognizer.Rule);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationAction(AnalyzeCompilation);
+ }
+
+ private static void AnalyzeCompilation(CompilationAnalysisContext context)
+ {
+ var compilation = context.Compilation;
+
+ var sigFiles = context.Options.AdditionalFiles
+ .Where(static f => f.Path.EndsWith(Extension, System.StringComparison.OrdinalIgnoreCase))
+ .ToImmutableArray();
+
+ // Nothing to enforce unless the project declares signatures. When one or more .cssig
+ // files are present they are all included by default and define the entire public surface.
+ if (sigFiles.IsEmpty)
+ {
+ return;
+ }
+
+ var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions
+ ?? CSharpParseOptions.Default;
+
+ var sigTrees = new List(sigFiles.Length);
+ var hadParseError = false;
+ foreach (var file in sigFiles)
+ {
+ var text = file.GetText(context.CancellationToken);
+ if (text is null)
+ {
+ continue;
+ }
+
+ var tree = CSharpSyntaxTree.ParseText(text, parseOptions, path: file.Path, cancellationToken: context.CancellationToken);
+
+ foreach (var diagnostic in tree.GetDiagnostics(context.CancellationToken))
+ {
+ if (diagnostic.Severity == DiagnosticSeverity.Error)
+ {
+ hadParseError = true;
+ context.ReportDiagnostic(Diagnostic.Create(
+ s_signatureFileError,
+ CsSigLocation.ToExternal(diagnostic.Location, file.Path),
+ diagnostic.GetMessage()));
+ }
+ }
+
+ // Recognize the .cssig grammar: reject any construct outside the signature sublanguage
+ // (the 'partial' modifier, member bodies, ...). This is purely syntactic and produces
+ // no model; the comparison model is derived from symbols below.
+ foreach (var diagnostic in CsSigRecognizer.Recognize(tree, context.CancellationToken))
+ {
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ sigTrees.Add(tree);
+ }
+
+ // If a .cssig file is malformed we can't reliably diff; surface the parse errors only.
+ if (hadParseError || sigTrees.Count == 0)
+ {
+ return;
+ }
+
+ var sigCompilation = CSharpCompilation.Create(
+ "__cssig__",
+ sigTrees,
+ compilation.References,
+ compilation.Options as CSharpCompilationOptions);
+
+ var declared = ApiSurface.Collect(sigCompilation.Assembly);
+ var actual = ApiSurface.Collect(compilation.Assembly);
+
+ var mode = ReadEquivalence(context.Options);
+
+ foreach (var pair in declared)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ if (!actual.TryGetValue(pair.Key, out var actualEntry))
+ {
+ // Declared in a .cssig file but missing from the project. An add/remove breaks
+ // whichever equivalence is being enforced.
+ context.ReportDiagnostic(Diagnostic.Create(
+ s_missingFromProject,
+ CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path),
+ pair.Value.Display,
+ Describe(mode)));
+ continue;
+ }
+
+ // Present on both sides: the identities match, so compare the equivalence projections
+ // that are active. A common-aspect change differs in both views, yielding a single
+ // diagnostic labelled with both equivalences.
+ var sourceDiffers = (mode & Equivalence.Source) != 0
+ && !Equals(pair.Value.Member.Source, actualEntry.Member.Source);
+ var binaryDiffers = (mode & Equivalence.Binary) != 0
+ && !Equals(pair.Value.Member.Binary, actualEntry.Member.Binary);
+
+ if (sourceDiffers || binaryDiffers)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ s_signatureMismatch,
+ CsSigLocation.ToExternal(pair.Value.Location, sigFiles[0].Path),
+ pair.Value.Display,
+ Describe(
+ (sourceDiffers ? Equivalence.Source : 0) | (binaryDiffers ? Equivalence.Binary : 0))));
+ }
+ }
+
+ // Part of the project's public API but not declared in any .cssig file.
+ foreach (var pair in actual)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+ if (!declared.ContainsKey(pair.Key))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ s_missingFromSignature,
+ pair.Value.Location,
+ pair.Value.Display,
+ Describe(mode)));
+ }
+ }
+ }
+
+ ///
+ /// Reads the CsSigEquivalence MSBuild property (Source / Binary / Both, default Both)
+ /// that selects which equivalence relation(s) the analyzer enforces.
+ ///
+ private static Equivalence ReadEquivalence(AnalyzerOptions options)
+ {
+ if (options.AnalyzerConfigOptionsProvider.GlobalOptions
+ .TryGetValue("build_property.CsSigEquivalence", out var raw)
+ && !string.IsNullOrWhiteSpace(raw))
+ {
+ switch (raw.Trim().ToLowerInvariant())
+ {
+ case "source":
+ return Equivalence.Source;
+ case "binary":
+ return Equivalence.Binary;
+ case "both":
+ case "strict":
+ return Equivalence.Both;
+ }
+ }
+
+ return Equivalence.Both;
+ }
+
+ private static string Describe(Equivalence equivalence) => equivalence switch
+ {
+ Equivalence.Source => "source",
+ Equivalence.Binary => "binary",
+ Equivalence.Both => "source and binary",
+ _ => "no",
+ };
+}
+
+/// The equivalence relation(s) the analyzer enforces between project and signatures.
+[System.Flags]
+internal enum Equivalence
+{
+ None = 0,
+
+ /// Members must be usable identically from source (names, optional/params, …).
+ Source = 1,
+
+ /// Members must be binary-compatible (const values baked into consumers, …).
+ Binary = 2,
+
+ /// Both source and binary equivalence are enforced.
+ Both = Source | Binary,
+}
diff --git a/src/CsSig/Analyzer/CsSigLocation.cs b/src/CsSig/Analyzer/CsSigLocation.cs
new file mode 100644
index 0000000..e1024af
--- /dev/null
+++ b/src/CsSig/Analyzer/CsSigLocation.cs
@@ -0,0 +1,29 @@
+using Microsoft.CodeAnalysis;
+
+namespace CsSig;
+
+///
+/// Helpers for reporting diagnostics against .cssig additional files.
+///
+internal static class CsSigLocation
+{
+ ///
+ /// Converts a location that lives in a synthetic .cssig syntax tree (which is not part
+ /// of the analyzed compilation) into an external-file location that can be safely reported.
+ ///
+ public static Location ToExternal(Location location, string fallbackPath)
+ {
+ if (!location.IsInSource)
+ {
+ return Location.None;
+ }
+
+ var path = location.SourceTree?.FilePath;
+ if (string.IsNullOrEmpty(path))
+ {
+ path = fallbackPath;
+ }
+
+ return Location.Create(path!, location.SourceSpan, location.GetLineSpan().Span);
+ }
+}
diff --git a/src/CsSig/Analyzer/CsSigRecognizer.cs b/src/CsSig/Analyzer/CsSigRecognizer.cs
new file mode 100644
index 0000000..0aff8bf
--- /dev/null
+++ b/src/CsSig/Analyzer/CsSigRecognizer.cs
@@ -0,0 +1,275 @@
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace CsSig;
+
+///
+/// Recognizes the .cssig grammar, a restricted sublanguage of C#, reporting a diagnostic
+/// (, CSSIG004) for every construct outside it.
+///
+///
+///
+/// C#'s own lexer/parser does the text-to-tree work (producing a ); this
+/// pass operates one level up, parsing that tree against the .cssig grammar and rejecting
+/// anything outside it. Its sole purpose is recognition: it produces ordinary analyzer diagnostics
+/// and no model. The structural model used for comparison is derived from symbols (see
+/// and ), so both the project and signature sides
+/// go through identical logic.
+///
+///
+/// The grammar is deliberately derived from what the comparison actually observes: a .cssig
+/// file may only express things that affect a signature. The comparison looks at accessibility,
+/// static, virtuality (virtual/abstract/override/sealed),
+/// type-level abstract/sealed, field readonly and const values, return
+/// and parameter types, and parameter ref/params kinds. Every other modifier (new,
+/// async, volatile, extern, unsafe, required, partial, …),
+/// member bodies, and non-const field initializers are invisible to it and are therefore
+/// rejected — declaring them would let a .cssig claim something the analyzer silently ignores.
+///
+///
+internal static class CsSigRecognizer
+{
+ /// Diagnostic reported for any construct outside the .cssig grammar.
+ public static readonly DiagnosticDescriptor Rule = new(
+ id: DiagId.DisallowedSignatureSyntax.ToIdString(),
+ title: "Disallowed construct in .cssig file",
+ messageFormat: "{0}",
+ category: "CsSig",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ ///
+ /// Recognizes against the .cssig grammar, yielding a diagnostic
+ /// for every construct that the grammar does not allow.
+ ///
+ public static IEnumerable Recognize(SyntaxTree tree, CancellationToken cancellationToken = default)
+ {
+ var walker = new Walker(tree.FilePath);
+ walker.Visit(tree.GetRoot(cancellationToken));
+ return walker.Diagnostics;
+ }
+
+ private sealed class Walker : CSharpSyntaxWalker
+ {
+ private readonly string _path;
+
+ public Walker(string path) => _path = path;
+
+ public List Diagnostics { get; } = new();
+
+ private void Report(Location location, string message)
+ => Diagnostics.Add(Diagnostic.Create(Rule, CsSigLocation.ToExternal(location, _path), message));
+
+ private static bool IsAccessibility(SyntaxKind kind)
+ => kind is SyntaxKind.PublicKeyword or SyntaxKind.PrivateKeyword
+ or SyntaxKind.ProtectedKeyword or SyntaxKind.InternalKeyword;
+
+ ///
+ /// Modifiers allowed on a virtualizable member (method, property, indexer, event): the
+ /// comparison captures all of these as part of the member's virtuality.
+ ///
+ private static readonly SyntaxKind[] s_memberModifiers =
+ {
+ SyntaxKind.StaticKeyword,
+ SyntaxKind.VirtualKeyword,
+ SyntaxKind.AbstractKeyword,
+ SyntaxKind.OverrideKeyword,
+ SyntaxKind.SealedKeyword,
+ };
+
+ // Members that can be 'readonly' on a struct (the modifier makes the member readonly,
+ // turning `this` into an `in` parameter — observable in both source and binary).
+ private static readonly SyntaxKind[] s_readonlyMemberModifiers =
+ {
+ SyntaxKind.StaticKeyword,
+ SyntaxKind.VirtualKeyword,
+ SyntaxKind.AbstractKeyword,
+ SyntaxKind.OverrideKeyword,
+ SyntaxKind.SealedKeyword,
+ SyntaxKind.ReadOnlyKeyword,
+ };
+
+ ///
+ /// Rejects every modifier that is not accessibility (always allowed, since it determines
+ /// surface membership) or one of the modifiers known to affect
+ /// the signature for this declaration kind.
+ ///
+ private void CheckModifiers(SyntaxTokenList modifiers, params SyntaxKind[] allowed)
+ {
+ foreach (var modifier in modifiers)
+ {
+ var kind = modifier.Kind();
+ if (IsAccessibility(kind) || System.Array.IndexOf(allowed, kind) >= 0)
+ {
+ continue;
+ }
+
+ Report(
+ modifier.GetLocation(),
+ $"The '{modifier.ValueText}' modifier does not affect the signature and is not allowed in a .cssig file");
+ }
+ }
+
+ private void RejectBody(BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody)
+ {
+ if (body is not null)
+ {
+ Report(
+ body.GetLocation(),
+ "Member bodies are not allowed in a .cssig file; signatures declare members without an implementation");
+ }
+
+ if (expressionBody is not null)
+ {
+ Report(
+ expressionBody.GetLocation(),
+ "Expression bodies are not allowed in a .cssig file; signatures declare members without an implementation");
+ }
+ }
+
+ public override void VisitClassDeclaration(ClassDeclarationSyntax node)
+ {
+ // 'static'/'abstract'/'sealed' all affect the type's signature (instantiation,
+ // extensibility of protected members, virtual dispatch).
+ CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.AbstractKeyword, SyntaxKind.SealedKeyword);
+ base.VisitClassDeclaration(node);
+ }
+
+ public override void VisitStructDeclaration(StructDeclarationSyntax node)
+ {
+ // Structs are always sealed and never static; only accessibility is observable.
+ CheckModifiers(node.Modifiers);
+ base.VisitStructDeclaration(node);
+ }
+
+ public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers);
+ base.VisitInterfaceDeclaration(node);
+ }
+
+ public override void VisitRecordDeclaration(RecordDeclarationSyntax node)
+ {
+ if (node.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword))
+ {
+ CheckModifiers(node.Modifiers);
+ }
+ else
+ {
+ CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.AbstractKeyword, SyntaxKind.SealedKeyword);
+ }
+
+ base.VisitRecordDeclaration(node);
+ }
+
+ public override void VisitEnumDeclaration(EnumDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers);
+ base.VisitEnumDeclaration(node);
+ }
+
+ public override void VisitDelegateDeclaration(DelegateDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers);
+ base.VisitDelegateDeclaration(node);
+ }
+
+ public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
+ {
+ // 'readonly' is allowed: on a struct instance method it marks the method readonly,
+ // making `this` an `in` parameter (observable in both source and binary).
+ CheckModifiers(node.Modifiers, s_readonlyMemberModifiers);
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitMethodDeclaration(node);
+ }
+
+ public override void VisitOperatorDeclaration(OperatorDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword);
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitOperatorDeclaration(node);
+ }
+
+ public override void VisitConversionOperatorDeclaration(ConversionOperatorDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword);
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitConversionOperatorDeclaration(node);
+ }
+
+ public override void VisitConstructorDeclaration(ConstructorDeclarationSyntax node)
+ {
+ // Only constructor accessibility is observable (via CanTypeBeExtended).
+ CheckModifiers(node.Modifiers);
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitConstructorDeclaration(node);
+ }
+
+ public override void VisitDestructorDeclaration(DestructorDeclarationSyntax node)
+ {
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitDestructorDeclaration(node);
+ }
+
+ public override void VisitPropertyDeclaration(PropertyDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, s_readonlyMemberModifiers);
+ RejectBody(body: null, node.ExpressionBody);
+ base.VisitPropertyDeclaration(node);
+ }
+
+ public override void VisitIndexerDeclaration(IndexerDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, s_readonlyMemberModifiers);
+ RejectBody(body: null, node.ExpressionBody);
+ base.VisitIndexerDeclaration(node);
+ }
+
+ public override void VisitEventDeclaration(EventDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, s_memberModifiers);
+ base.VisitEventDeclaration(node);
+ }
+
+ public override void VisitEventFieldDeclaration(EventFieldDeclarationSyntax node)
+ {
+ CheckModifiers(node.Modifiers, s_memberModifiers);
+ base.VisitEventFieldDeclaration(node);
+ }
+
+ public override void VisitFieldDeclaration(FieldDeclarationSyntax node)
+ {
+ // 'static' -> ApiMember.IsStatic; 'const' -> the captured constant value;
+ // 'readonly' -> the field's read-only-ness (observable to external writers).
+ CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.ConstKeyword, SyntaxKind.ReadOnlyKeyword);
+
+ // A field's value is only part of the signature when it is 'const'; any other
+ // initializer is invisible to the comparison.
+ if (!node.Modifiers.Any(SyntaxKind.ConstKeyword))
+ {
+ foreach (var variable in node.Declaration.Variables)
+ {
+ if (variable.Initializer is { } initializer)
+ {
+ Report(
+ initializer.GetLocation(),
+ "A field initializer does not affect the signature and is not allowed in a .cssig file; only 'const' values are part of the signature");
+ }
+ }
+ }
+
+ base.VisitFieldDeclaration(node);
+ }
+
+ public override void VisitAccessorDeclaration(AccessorDeclarationSyntax node)
+ {
+ // Accessor accessibility (e.g. 'private set') determines whether the accessor surfaces.
+ CheckModifiers(node.Modifiers);
+ RejectBody(node.Body, node.ExpressionBody);
+ base.VisitAccessorDeclaration(node);
+ }
+ }
+}
diff --git a/src/CsSig/Analyzer/CsSigWriter.cs b/src/CsSig/Analyzer/CsSigWriter.cs
new file mode 100644
index 0000000..1df6e48
--- /dev/null
+++ b/src/CsSig/Analyzer/CsSigWriter.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using StaticCs;
+
+namespace CsSig;
+
+///
+/// Generates the text of a .cssig file from a compilation's public API surface: the inverse
+/// of . Every externally visible member is emitted as a body-less C#
+/// declaration, grouped by namespace and nesting, so that feeding the result back through
+/// produces no diagnostics.
+///
+///
+/// Only explicitly declared members are emitted; implicit members (default constructors, record
+/// members, …) are intentionally omitted because the same declaration causes the compiler to
+/// synthesize identical members on both the project and the generated signature.
+///
+public static class CsSigWriter
+{
+ // Full signature of a non-type member, body-less. Types are fully qualified so the generated
+ // file needs no using directives. SymbolDisplay omits non-signature modifiers such as `unsafe`
+ // and `async`; the few it still emits (`volatile`, `required`) are scrubbed afterwards.
+ private static readonly SymbolDisplayFormat s_memberFormat = new(
+ globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining,
+ typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
+ genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
+ memberOptions:
+ SymbolDisplayMemberOptions.IncludeParameters |
+ SymbolDisplayMemberOptions.IncludeType |
+ SymbolDisplayMemberOptions.IncludeModifiers |
+ SymbolDisplayMemberOptions.IncludeAccessibility |
+ SymbolDisplayMemberOptions.IncludeConstantValue |
+ SymbolDisplayMemberOptions.IncludeRef |
+ SymbolDisplayMemberOptions.IncludeExplicitInterface,
+ kindOptions: SymbolDisplayKindOptions.IncludeMemberKeyword,
+ propertyStyle: SymbolDisplayPropertyStyle.ShowReadWriteDescriptor,
+ parameterOptions:
+ SymbolDisplayParameterOptions.IncludeType |
+ SymbolDisplayParameterOptions.IncludeName |
+ SymbolDisplayParameterOptions.IncludeParamsRefOut |
+ SymbolDisplayParameterOptions.IncludeExtensionThis |
+ SymbolDisplayParameterOptions.IncludeDefaultValue,
+ miscellaneousOptions:
+ SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
+ SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier |
+ SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
+
+ // A bare type reference, fully qualified, for return/parameter/underlying types.
+ private static readonly SymbolDisplayFormat s_typeFormat = new(
+ globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining,
+ typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
+ genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
+ miscellaneousOptions:
+ SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
+ SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier |
+ SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
+
+ /// Generates the .cssig text for 's public API.
+ public static string Write(Compilation compilation) => Write(compilation.Assembly);
+
+ /// Generates the .cssig text for 's public API.
+ public static string Write(IAssemblySymbol assembly) => Write(assembly, topLevelKeys: null);
+
+ ///
+ /// Generates the .cssig text for the subset of 's public API
+ /// whose top-level types are named in (see
+ /// ). When is
+ /// the entire surface is written. The code fix uses this to (re)generate a single signature
+ /// file that owns a particular set of top-level types.
+ ///
+ public static string Write(IAssemblySymbol assembly, ISet? topLevelKeys)
+ {
+ var byNamespace = new SortedDictionary>(StringComparer.Ordinal);
+ foreach (var type in TopLevelTypes(assembly.GlobalNamespace))
+ {
+ if (!ApiSurface.IsTrackedApi(type))
+ {
+ continue;
+ }
+
+ if (topLevelKeys is not null && !topLevelKeys.Contains(TopLevelKey(type)))
+ {
+ continue;
+ }
+
+ var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n ? n.ToDisplayString() : string.Empty;
+ if (!byNamespace.TryGetValue(ns, out var list))
+ {
+ byNamespace[ns] = list = new List();
+ }
+
+ list.Add(type);
+ }
+
+ var builder = new IndentingBuilder();
+ var first = true;
+ foreach (var entry in byNamespace)
+ {
+ if (!first)
+ {
+ builder.AppendLine("");
+ }
+
+ first = false;
+
+ if (entry.Key.Length == 0)
+ {
+ foreach (var type in Sorted(entry.Value))
+ {
+ WriteType(builder, type);
+ }
+ }
+ else
+ {
+ builder.AppendLine($"namespace {entry.Key}");
+ builder.AppendLine("{");
+ builder.Indent();
+ foreach (var type in Sorted(entry.Value))
+ {
+ WriteType(builder, type);
+ }
+
+ builder.Dedent();
+ builder.AppendLine("}");
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ private static void WriteType(IndentingBuilder builder, INamedTypeSymbol type)
+ {
+ if (type.TypeKind == TypeKind.Delegate)
+ {
+ builder.AppendLine(DelegateDeclaration(type) + ";");
+ return;
+ }
+
+ builder.AppendLine(TypeHeader(type));
+ builder.AppendLine("{");
+ builder.Indent();
+
+ if (type.TypeKind == TypeKind.Enum)
+ {
+ foreach (var field in type.GetMembers().OfType().Where(f => f.HasConstantValue))
+ {
+ builder.AppendLine(FormatMember(field));
+ }
+ }
+ else
+ {
+ foreach (var member in Sorted(VisibleMembers(type)))
+ {
+ builder.AppendLine(FormatMember(member));
+ }
+
+ foreach (var nested in Sorted(type.GetMembers().OfType().Where(ApiSurface.IsTrackedApi)))
+ {
+ WriteType(builder, nested);
+ }
+ }
+
+ builder.Dedent();
+ builder.AppendLine("}");
+ }
+
+ /// Generates the body-less declaration text of a single member, exactly as it should
+ /// appear inside a type in a .cssig file (no leading indentation, no trailing newline).
+ /// Enum members are rendered as Name = value,.
+ private static string FormatMember(ISymbol member)
+ {
+ if (member is IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum } enumField)
+ {
+ return $"{enumField.Name} = {FormatConstant(enumField.ConstantValue)},";
+ }
+
+ var text = member.ToDisplayString(s_memberFormat)
+ .Replace("volatile ", string.Empty)
+ .Replace("required ", string.Empty);
+
+ // ShowReadWriteDescriptor already renders the `{ get; set; }` body for properties/indexers,
+ // so only non-property members need a terminating semicolon.
+ return member is IPropertySymbol ? text : text + ";";
+ }
+
+ private static string TypeHeader(INamedTypeSymbol type)
+ {
+ var parts = new List { Accessibility(type.DeclaredAccessibility) };
+
+ if (type.IsStatic)
+ {
+ parts.Add("static");
+ }
+ else if (type.TypeKind == TypeKind.Class)
+ {
+ if (type.IsAbstract)
+ {
+ parts.Add("abstract");
+ }
+
+ if (type.IsSealed)
+ {
+ parts.Add("sealed");
+ }
+ }
+
+ parts.Add(type.TypeKind switch
+ {
+ TypeKind.Class => type.IsRecord ? "record" : "class",
+ TypeKind.Struct => type.IsRecord ? "record struct" : "struct",
+ TypeKind.Interface => "interface",
+ TypeKind.Enum => "enum",
+ _ => "class",
+ });
+
+ parts.Add(type.Name + TypeParameterList(type));
+ return string.Join(" ", parts);
+ }
+
+ private static string DelegateDeclaration(INamedTypeSymbol type)
+ {
+ var invoke = type.DelegateInvokeMethod!;
+ var @return = (invoke.ReturnsByRef ? "ref " : invoke.ReturnsByRefReadonly ? "ref readonly " : string.Empty)
+ + invoke.ReturnType.ToDisplayString(s_typeFormat);
+ return $"{Accessibility(type.DeclaredAccessibility)} delegate {@return} "
+ + $"{type.Name}{TypeParameterList(type)}({FormatParameters(invoke.Parameters)})";
+ }
+
+ private static string TypeParameterList(INamedTypeSymbol type)
+ => type.TypeParameters.IsEmpty
+ ? string.Empty
+ : "<" + string.Join(", ", type.TypeParameters.Select(p => p.Name)) + ">";
+
+ private static string FormatParameters(IEnumerable parameters)
+ => string.Join(", ", parameters.Select(p =>
+ {
+ var prefix = p.RefKind switch
+ {
+ RefKind.Ref => "ref ",
+ RefKind.Out => "out ",
+ RefKind.In => "in ",
+ _ => string.Empty,
+ };
+ return prefix + p.Type.ToDisplayString(s_typeFormat) + " " + p.Name;
+ }));
+
+ /// The members of that should appear in the signature file:
+ /// explicitly declared, externally visible non-type members, excluding accessors (emitted via
+ /// their property/event) so the set matches what compares.
+ private static IEnumerable VisibleMembers(INamedTypeSymbol type)
+ {
+ foreach (var member in type.GetMembers())
+ {
+ if (member is INamedTypeSymbol || member.IsImplicitlyDeclared)
+ {
+ continue;
+ }
+
+ if (member is IMethodSymbol
+ {
+ MethodKind: MethodKind.PropertyGet or MethodKind.PropertySet
+ or MethodKind.EventAdd or MethodKind.EventRemove,
+ })
+ {
+ continue;
+ }
+
+ if (IsVisible(member))
+ {
+ yield return member;
+ }
+ }
+ }
+
+ private static bool IsVisible(ISymbol member) => member switch
+ {
+ // Properties are tracked through their accessors; emit the property if either surfaces.
+ IPropertySymbol property =>
+ (property.GetMethod is { } getter && ApiSurface.IsTrackedApi(getter))
+ || (property.SetMethod is { } setter && ApiSurface.IsTrackedApi(setter)),
+ _ => ApiSurface.IsTrackedApi(member),
+ };
+
+ private static IEnumerable TopLevelTypes(INamespaceSymbol root)
+ {
+ var stack = new Stack();
+ stack.Push(root);
+ while (stack.Count > 0)
+ {
+ foreach (var member in stack.Pop().GetMembers())
+ {
+ switch (member)
+ {
+ case INamespaceSymbol ns:
+ stack.Push(ns);
+ break;
+ case INamedTypeSymbol type:
+ yield return type;
+ break;
+ }
+ }
+ }
+ }
+
+ private static IEnumerable Sorted(IEnumerable symbols) where T : ISymbol
+ => symbols.OrderBy(s => s.ToDisplayString(s_memberFormat), StringComparer.Ordinal);
+
+ ///
+ /// A stable identity for a top-level type — its namespace-qualified name plus generic
+ /// arity (e.g. N.Outer`1). Used to decide which signature file owns a type. The same
+ /// string can be reconstructed from a .cssig type declaration's syntax, so the code fix
+ /// can match a declared type back to its project symbol.
+ ///
+ public static string TopLevelKey(INamedTypeSymbol type)
+ {
+ var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n ? n.ToDisplayString() + "." : string.Empty;
+ var name = type.Arity > 0 ? type.Name + "`" + type.Arity : type.Name;
+ return ns + name;
+ }
+
+ private static string Accessibility(Accessibility accessibility) => accessibility switch
+ {
+ Microsoft.CodeAnalysis.Accessibility.Public => "public",
+ Microsoft.CodeAnalysis.Accessibility.Protected => "protected",
+ Microsoft.CodeAnalysis.Accessibility.ProtectedOrInternal => "protected internal",
+ Microsoft.CodeAnalysis.Accessibility.ProtectedAndInternal => "private protected",
+ Microsoft.CodeAnalysis.Accessibility.Internal => "internal",
+ Microsoft.CodeAnalysis.Accessibility.Private => "private",
+ _ => "internal",
+ };
+
+ private static string FormatConstant(object? value)
+ => value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null";
+}
diff --git a/src/CsSig/Analyzer/DiagIds.cs b/src/CsSig/Analyzer/DiagIds.cs
new file mode 100644
index 0000000..6046455
--- /dev/null
+++ b/src/CsSig/Analyzer/DiagIds.cs
@@ -0,0 +1,29 @@
+namespace CsSig;
+
+///
+/// Diagnostic ids reported by the .cssig analyzer.
+///
+public enum DiagId
+{
+ /// A signature declared in a .cssig file is not present in the project.
+ MissingFromProject = 1,
+
+ /// A public API member in the project is not declared in any .cssig file.
+ MissingFromSignature = 2,
+
+ /// A .cssig file could not be parsed.
+ SignatureFileError = 3,
+
+ /// A .cssig file uses a construct that is not allowed in a signature file.
+ DisallowedSignatureSyntax = 4,
+
+ /// A member exists on both sides but its signature is not equivalent.
+ SignatureMismatch = 5,
+}
+
+public static class DiagUtils
+{
+ private const string DiagPrefix = "CSSIG";
+
+ public static string ToIdString(this DiagId id) => $"{DiagPrefix}{(int)id:D3}";
+}
diff --git a/src/CsSig/Analyzer/EqArray.cs b/src/CsSig/Analyzer/EqArray.cs
new file mode 100644
index 0000000..1a2ea0d
--- /dev/null
+++ b/src/CsSig/Analyzer/EqArray.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+
+namespace CsSig;
+
+///
+/// A small immutable array wrapper that provides structural (element-wise) equality, so it can be
+/// used as a member of record types and still participate correctly in value equality.
+///
+internal readonly struct EqArray(ImmutableArray _array) : IEquatable>
+ where T : IEquatable
+{
+ public ImmutableArray Array => _array.IsDefault ? ImmutableArray.Empty : _array;
+
+ public int Length => Array.Length;
+
+ public T this[int index] => Array[index];
+
+ public ImmutableArray.Enumerator GetEnumerator() => Array.GetEnumerator();
+
+ public bool Equals(EqArray other)
+ {
+ var left = Array;
+ var right = other.Array;
+ if (left.Length != right.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Length; i++)
+ {
+ if (!EqualityComparer.Default.Equals(left[i], right[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public override bool Equals(object? obj) => obj is EqArray other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ foreach (var item in Array)
+ {
+ hash = unchecked((hash * 31) + (item?.GetHashCode() ?? 0));
+ }
+
+ return hash;
+ }
+
+ public static EqArray From(IEnumerable items) => new(items.ToImmutableArray());
+}
diff --git a/src/CsSig/Analyzer/GRAMMAR.md b/src/CsSig/Analyzer/GRAMMAR.md
new file mode 100644
index 0000000..c1aebf8
--- /dev/null
+++ b/src/CsSig/Analyzer/GRAMMAR.md
@@ -0,0 +1,148 @@
+# The `.cssig` grammar
+
+A `.cssig` file describes a project's externally visible API surface using ordinary C# member
+declarations **with no bodies**. This document specifies exactly which C# constructs are legal in a
+`.cssig` file.
+
+The grammar is a **restricted sublanguage of C#**. A `.cssig` file is first parsed by the C#
+parser (so its lexical and syntactic structure is exactly C#'s), and is then validated by the
+[recognizer](CsSigRecognizer.cs) against the additional rules below. Anything the C# parser rejects
+is reported as **`CSSIG003`**; anything the recognizer rejects is reported as **`CSSIG004`**.
+
+## Guiding principle
+
+> A `.cssig` file may only express things that affect a signature.
+
+The set of legal constructs is **derived from what the comparison observes** (see
+[`SignatureModel.cs`](SignatureModel.cs) and [`ApiSurface.cs`](ApiSurface.cs)). If a construct is
+invisible to the comparison, declaring it would let a `.cssig` silently claim something the analyzer
+ignores, so the recognizer rejects it. When a new distinction should matter, it is added to *both*
+the comparison and this grammar together.
+
+## Overall shape
+
+A `.cssig` file is a C# compilation unit:
+
+```ebnf
+compilation-unit = { using-directive } ( file-namespace | { namespace | type-declaration } ) ;
+file-namespace = "namespace" qualified-name ";" { using-directive } { type-declaration } ;
+namespace = "namespace" qualified-name "{" { using-directive } { type-declaration } "}" ;
+```
+
+`using` directives and namespace declarations are unrestricted — they exist to bring names into
+scope and to place declarations, neither of which is a signature claim.
+
+## Type declarations
+
+Legal type declarations are `class`, `struct`, `interface`, `record` (class or struct), `enum`, and
+`delegate`. Each may carry only the modifiers that affect the surface:
+
+| Declaration | Allowed modifiers | Rationale |
+| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------- |
+| `class`, `record class`| accessibility, `static`, `abstract`, `sealed` | packed into `CommonTypeAspects.Flags`; `abstract`/`sealed` affect instantiability, virtual dispatch, and whether protected members surface (`CanTypeBeExtended`) |
+| `struct`, `record struct`, `interface`, `enum`, `delegate` | accessibility | structs are always sealed/never static; the rest have no observable type modifier |
+
+"accessibility" means any of `public`, `private`, `protected`, `internal` (including the
+combinations `protected internal` and `private protected`).
+
+## Members
+
+| Member | Allowed modifiers | Body? | Notes |
+| ------------------------------------------ | -------------------------------- | ---------------- | --------------------------------------------------------------- |
+| method | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed`, `readonly` | none | declared as `T M(params);`; virtuality is part of the signature; `readonly` allowed on struct methods |
+| operator, conversion operator | accessibility, `static` | none | |
+| constructor | accessibility | none | constructor accessibility feeds `CanTypeBeExtended` |
+| destructor | (none) | none | |
+| property, indexer | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed`, `readonly` | no expression body | accessors are written as `{ get; set; }` (see below); `readonly` allowed on struct members |
+| accessor (`get` / `set` / `init`) | accessibility | none | `private set` etc. controls whether the accessor surfaces |
+| event (field-form or with accessors) | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed` | n/a | typically `event T E;` |
+| field | accessibility, `static`, `const`, `readonly` | n/a | initializer allowed **only** with `const` (see below) |
+| enum member | (none) | n/a | an explicit `= value` is allowed and is part of the signature |
+
+`virtual` / `abstract` / `override` / `sealed` on a member are captured as its *virtuality* and
+affect both equivalences (see below). Interface members are implicitly `abstract`, so the modifier
+need not be written there.
+
+### No bodies
+
+Member bodies are not part of a signature, so they are prohibited everywhere:
+
+- block bodies (`{ … }`) on methods, operators, constructors, destructors, and accessors;
+- expression bodies (`=> …`) on methods, operators, properties, indexers, and accessors.
+
+A body-less method such as `int M();` is a *semantic* error in plain C# (CS0501); the analyzer
+parses it successfully and intentionally ignores that semantic error — only the signature matters.
+
+### Field initializers
+
+A field's value is part of the signature **only** when the field is `const` (the comparison
+captures the constant value). A `const` field therefore requires its initializer:
+
+```csharp
+public const int Limit = 100; // legal: the value is part of the signature
+```
+
+An initializer on any non-`const` field is invisible to the comparison and is rejected:
+
+```csharp
+public static int Count = 0; // CSSIG004: the initializer is ignored — drop it
+public static int Count; // legal
+```
+
+### Unsafe types
+
+The `unsafe` modifier has no signature impact, so it is rejected like any other non-signature
+modifier. Pointer (`int*`) and function-pointer (`delegate*<…>`) types, however, are part of the
+signature and are compared structurally. Write such members **without** `unsafe`:
+
+```csharp
+public unsafe delegate* Callback; // CSSIG004: drop 'unsafe'
+public delegate* Callback; // legal
+```
+
+Omitting `unsafe` makes the declaration a *semantic* error in plain C# (CS0214), exactly as a
+body-less `int M();` is (CS0501); the analyzer parses it successfully and intentionally ignores
+that semantic error — only the signature matters.
+
+## Currently accepted but not part of the comparison
+
+The following constructs are **syntactically accepted** today (the recognizer does not reject them),
+but the comparison does **not** currently observe them, so they have no effect. They are candidates
+for either rejection or — more likely — being folded into the comparison later:
+
+- generic constraints (`where T : …`);
+- base types and implemented interfaces (`class C : IFoo`);
+- generic type-parameter variance (`in` / `out`);
+- `ref` / `ref readonly` returns;
+- the `scoped` parameter modifier;
+- attributes.
+
+Until that is decided, do not rely on any of these to change what the analyzer enforces.
+
+## Source vs. binary equivalence
+
+The comparison enforces one or both of two equivalence relations, selected by the
+`CsSigEquivalence` MSBuild property (`Source`, `Binary`, or `Both` — the default):
+
+- **Common aspects** (compared in *both*): kind, name, arity, parameter types and ref kinds, return
+ / field / event type, `static`, virtuality, type-level `abstract` / `sealed`, field `readonly`,
+ struct-member `readonly`, and const-ness. A difference here breaks both equivalences.
+- **Source-only**: parameter *names*, `params`, extension `this`, whether a parameter is optional,
+ `in` vs `ref readonly`, and nullable reference type annotations (`string?`). These change source
+ call sites but not the binary calling convention.
+- **Binary-only**: the `const` *value*, which is baked into already-compiled consumers (a source
+ recompile picks up a new value).
+
+Because the grammar only admits constructs the comparison observes, every legal modifier above feeds
+one of these buckets.
+
+## Diagnostics summary
+
+| Id | Meaning |
+| --------- | ----------------------------------------------------------------------- |
+| `CSSIG003`| the file is not valid C# (a parse error) |
+| `CSSIG004`| the file uses a construct outside this grammar |
+
+(`CSSIG001` / `CSSIG002` / `CSSIG005` are reported by the *comparison*, not the grammar: a declared
+signature missing from the project, a public member missing from the signature files, or a member
+present on both sides whose signature is not equivalent. Each names the equivalence it breaks.)
diff --git a/src/CsSig/Analyzer/IsExternalInit.cs b/src/CsSig/Analyzer/IsExternalInit.cs
new file mode 100644
index 0000000..476a949
--- /dev/null
+++ b/src/CsSig/Analyzer/IsExternalInit.cs
@@ -0,0 +1,8 @@
+namespace System.Runtime.CompilerServices
+{
+ // Required so that `record` types and `init` accessors can be used when targeting
+ // netstandard2.0 (which does not ship the IsExternalInit type).
+ internal static class IsExternalInit
+ {
+ }
+}
diff --git a/src/CsSig/Analyzer/README.md b/src/CsSig/Analyzer/README.md
new file mode 100644
index 0000000..7680188
--- /dev/null
+++ b/src/CsSig/Analyzer/README.md
@@ -0,0 +1,136 @@
+# CsSig — C# signature files (`.cssig`)
+
+`StaticCS.CsSig` is a Roslyn analyzer, in the spirit of the
+[Public API analyzer](https://github.com/dotnet/roslyn/tree/main/src/RoslynAnalyzers/PublicApiAnalyzers),
+that lets you pin a project's **public API surface** using ordinary C#.
+
+Instead of a flat text format, the expected surface is described in a `.cssig` file containing
+real C# member declarations **with no bodies**. The analyzer verifies that the project's public
+API surface *exactly matches* the declared signatures — in both directions.
+
+## Example
+
+`Api.cssig`:
+
+```csharp
+namespace MyLibrary;
+
+public class Greeter
+{
+ public Greeter(string name);
+ public string Greet();
+ public string Name { get; }
+}
+```
+
+If the project's public API drifts from this file, you get a build error:
+
+- `CSSIG001` — a signature is declared in a `.cssig` file but is **missing from the project**.
+- `CSSIG002` — a public member exists in the project but is **not declared** in any `.cssig` file.
+- `CSSIG003` — a `.cssig` file could not be parsed.
+- `CSSIG004` — a `.cssig` file uses a construct outside the signature grammar. The grammar is derived from what the comparison observes, so a `.cssig` can only express things that affect a signature. This rejects modifiers that don't change a signature (`new`, `async`, `volatile`, `extern`, `unsafe`, `required`, `partial`, …), member bodies, and non-`const` field initializers. The modifiers that *are* allowed are accessibility, `static`, `abstract`/`sealed` (types), member virtuality (`virtual`/`abstract`/`override`/`sealed`), `const`/`readonly` (fields), `readonly` (struct methods/properties/indexers), and parameter `ref`/`in`/`out`/`params`/`this`. See [`GRAMMAR.md`](GRAMMAR.md) for the full specification.
+- `CSSIG005` — a member is present on both sides but its signature is **not equivalent** (e.g. a changed return type, virtuality, parameter name, or `const` value). The message names which equivalence is broken.
+
+## Source vs. binary equivalence
+
+The analyzer enforces one or both of two equivalence relations, chosen with the `CsSigEquivalence`
+MSBuild property — `Source`, `Binary`, or `Both` (the default):
+
+```xml
+
+ Both
+
+```
+
+- **Common** aspects break *both*: types, `static`, virtuality, type-level `abstract`/`sealed`,
+ field `readonly`/const-ness, struct-member `readonly` (it makes `this` an `in` parameter), and
+ return/field/event/parameter types.
+- **Source-only** aspects break source equivalence only: parameter *names*, `params`, extension
+ `this`, whether a parameter is optional, `in` vs `ref readonly`, and nullable reference type
+ annotations (`string?`) — they change source call sites but not the binary calling convention.
+- **Binary-only** aspects break binary equivalence only: a `const` *value*, which is baked into
+ already-compiled consumers (a source recompile picks up a new value).
+
+Every `CSSIG001`/`CSSIG002`/`CSSIG005` message states which equivalence(s) it breaks.
+
+## How it works
+
+The analyzer parses every `.cssig` additional file into a *synthetic* compilation (using the
+project's own references and compilation options), then produces symbols from both that synthetic
+compilation and the project. Each externally-visible member is reduced to a structural model
+(`ApiMember` — a `MemberIdentity` pairing key plus two projections, `SourceMember` and
+`BinaryMember`, whose record equality *is* source/binary equivalence; types are captured as a
+recursive structural `TypeRef`). Members are paired by identity, and each pair's active projections
+are compared. Bodyless methods (`int M();`) produce a semantic "missing body"
+diagnostic in the synthetic compilation, which is intentionally ignored — only the signature
+matters.
+
+Before comparison, each `.cssig` file is run through a **recognizer** pass that validates it
+against the `.cssig` grammar — a restricted sublanguage of C#. C#'s own parser does the
+text-to-tree work; the recognizer parses that tree against the signature grammar and rejects
+anything outside it as `CSSIG004`. It is purely syntactic and produces no model: the comparison
+model is always derived from symbols, so the project and signature sides go through identical logic.
+
+The grammar is **derived from what the comparison observes** — a `.cssig` may only express things
+that affect a signature. The comparison looks at accessibility, `static`, virtuality, type-level
+`abstract`/`sealed`, ctor accessibility (extensibility of protected members), field
+`readonly`/`const` values, and return/field/event/parameter types and ref kinds; every other
+modifier, member bodies, and non-`const` field initializers are invisible to it and are
+rejected. This keeps a `.cssig` from silently claiming something the analyzer ignores; if a future
+distinction (e.g. nullability) should matter, it is added to *both* the comparison and the grammar
+together.
+
+The public-API-surface rules (which members count, how protected members on extensible types are
+handled, implicit constructors and record members, etc.) and the canonical signature format are
+ported from the Roslyn Public API analyzer.
+
+## Usage
+
+Add a package reference and drop a `.cssig` file next to your code:
+
+```xml
+
+
+
+```
+
+All `*.cssig` files anywhere in the project are included by default (as `AdditionalFiles`) and
+together define the project's entire public API surface. There is no shipped/unshipped split — every
+`.cssig` file is treated the same. To opt out of the auto-include entirely, set
+`false`.
+
+If a project has no `.cssig` files, there is nothing to enforce and the analyzer does nothing.
+
+## Generating `.cssig` files
+
+You don't have to write a `.cssig` file by hand. A code fix on `CSSIG002` regenerates the
+signature file from the project's current public API, mirroring the Public API analyzer's
+"Add to public API" fix.
+
+The bootstrap workflow:
+
+1. Create an empty `.cssig` file in the project (e.g. `Api.cssig`).
+2. Build. Because the file declares nothing, **every** public member surfaces a `CSSIG002`.
+3. In the editor, invoke the code fix on any `CSSIG002` and choose **Fix all** (document, project,
+ or solution). The fix rewrites the whole file from the current surface, so a single application
+ resolves every outstanding `CSSIG002` at once.
+
+The same fix keeps an existing `.cssig` up to date: when you add public members, apply the fix to
+any new `CSSIG002` to regenerate the file with the additions. If a project has more than one
+`.cssig` file, the fix targets the first one.
+
+The regenerated text is exactly what the analyzer expects to round-trip with zero diagnostics:
+members are emitted as body-less declarations grouped by namespace and nesting, with non-signature
+modifiers (`async`, `unsafe`, `volatile`, …) omitted.
+
+## Notes
+
+- Accessibility and type-kind keywords (`public`, `class`, …) are part of the C# you write, but the
+ comparison is over the *externally visible surface*: a member written without `public` simply
+ isn't part of the declared surface.
+- Nullable reference type annotations (`string?`) are part of *source* equivalence only: a
+ `string`/`string?` difference is reported when source (or both) equivalence is enforced, and
+ ignored under binary equivalence. They are only meaningful when the project compiles with nullable
+ reference types enabled; otherwise every reference type is oblivious on both sides and matches.
+- Editor support (syntax highlighting) for `.cssig` files is provided by the VS Code extension under
+ [`src/CsSig/vscode`](../vscode).
diff --git a/src/CsSig/Analyzer/SignatureModel.cs b/src/CsSig/Analyzer/SignatureModel.cs
new file mode 100644
index 0000000..5a8cbc3
--- /dev/null
+++ b/src/CsSig/Analyzer/SignatureModel.cs
@@ -0,0 +1,407 @@
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Linq;
+using System.Reflection.Metadata;
+using Microsoft.CodeAnalysis;
+
+namespace CsSig;
+
+///
+/// A canonical, structural reference to a type. Two type references from different compilations
+/// are equal when they denote the same type, regardless of how they were written.
+///
+internal abstract partial record TypeRef
+{
+ // Closed hierarchy: only the nested cases below may derive from TypeRef.
+ private TypeRef() { }
+
+ public sealed record Named(
+ string Namespace,
+ TypeRef? ContainingType,
+ string Name,
+ EqArray TypeArguments) : TypeRef;
+ public sealed record Array(TypeRef ElementType, int Rank) : TypeRef;
+ public sealed record Pointer(TypeRef ElementType) : TypeRef;
+ public sealed record TypeParameter(int Ordinal, bool IsMethodTypeParameter) : TypeRef;
+ public sealed record FunctionPointer(
+ SignatureCallingConvention CallingConvention,
+ EqArray CallingConventionTypes,
+ ParamKey Return,
+ EqArray Parameters) : TypeRef;
+ public sealed record Dynamic : TypeRef
+ {
+ public static readonly Dynamic Instance = new();
+ }
+}
+
+partial record TypeRef
+{
+ public static TypeRef From(ITypeSymbol type)
+ {
+ switch (type)
+ {
+ case IArrayTypeSymbol array:
+ return new Array(From(array.ElementType), array.Rank);
+
+ case IPointerTypeSymbol pointer:
+ return new Pointer(From(pointer.PointedAtType));
+
+ case ITypeParameterSymbol typeParameter:
+ return new TypeParameter(
+ typeParameter.Ordinal,
+ typeParameter.TypeParameterKind == TypeParameterKind.Method);
+
+ case IDynamicTypeSymbol:
+ return Dynamic.Instance;
+
+ case IFunctionPointerTypeSymbol functionPointer:
+ {
+ var signature = functionPointer.Signature;
+ var callingConventionTypes = EqArray.From(
+ signature.UnmanagedCallingConventionTypes.Select(From));
+ var @return = new ParamKey(From(signature.ReturnType), signature.RefKind);
+ var parameters = EqArray.From(
+ signature.Parameters.Select(p => new ParamKey(From(p.Type), p.RefKind)));
+ return new FunctionPointer(
+ signature.CallingConvention, callingConventionTypes, @return, parameters);
+ }
+
+ case INamedTypeSymbol named:
+ var container = named.ContainingType is { } containingType ? From(containingType) : null;
+ var @namespace = container is null ? NamespaceName(named.ContainingNamespace) : string.Empty;
+ return new Named(
+ @namespace,
+ container,
+ named.Name,
+ EqArray.From(named.TypeArguments.Select(From)));
+
+ default:
+ // The ITypeSymbol hierarchy above is exhaustive (array, pointer, type parameter,
+ // dynamic, function pointer, named/error). Anything else cannot be modeled
+ // structurally, so fail loudly rather than invent a comparison.
+ throw new ArgumentException(
+ $"Cannot build a type reference from type kind '{type.TypeKind}'.", nameof(type));
+ }
+ }
+
+ private static string NamespaceName(INamespaceSymbol? @namespace)
+ => @namespace is null || @namespace.IsGlobalNamespace
+ ? string.Empty
+ : @namespace.ToDisplayString();
+}
+
+internal enum ApiMemberKind
+{
+ Type,
+ Method,
+ Field,
+ Event,
+}
+
+///
+/// The boolean aspects of a member that are common to both equivalences (changing any breaks both),
+/// packed into a single value. A type uses //
+/// ; a method or event uses plus the virtuality bits
+/// (///); a
+/// field uses //. The
+/// / bits are shared between type-level and member-level
+/// meanings, which never apply to the same member.
+///
+[Flags]
+internal enum MemberFlags
+{
+ None = 0,
+ Static = 1 << 0,
+ Abstract = 1 << 1,
+ Sealed = 1 << 2,
+ Virtual = 1 << 3,
+ Override = 1 << 4,
+ ReadOnly = 1 << 5,
+ HasConstantValue = 1 << 6,
+}
+
+/// The source-only modifiers of a parameter, packed into a single value.
+[Flags]
+internal enum ParamModifiers
+{
+ None = 0,
+ Params = 1 << 0,
+ This = 1 << 1,
+ Optional = 1 << 2,
+
+ ///
+ /// The parameter is ref readonly rather than in. The two share a binary calling
+ /// convention (see ), so this distinction affects source equivalence only.
+ ///
+ RefReadOnly = 1 << 3,
+}
+
+///
+/// The nullable reference type annotations of a type, flattened in a deterministic pre-order walk of
+/// its structure (one entry per type node, e.g. string? on the array and its element for
+/// string?[]?). NRT annotations affect source equivalence only — they are erased to
+/// attributes in metadata and never change a calling convention, so they are carried by the source
+/// projection alone (mirroring how is source-only). Each
+/// byte is the numeric (0 = oblivious, 1 = not annotated, 2 =
+/// annotated). When the project compiles without nullable reference types every node is oblivious on
+/// both the project and the .cssig side, so the annotations compare equal and nothing is
+/// enforced.
+///
+internal readonly record struct Nullability(EqArray Annotations)
+{
+ public static Nullability Of(ITypeSymbol type)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+ Walk(type, builder);
+ return new Nullability(EqArray.From(builder));
+ }
+
+ private static void Walk(ITypeSymbol type, ImmutableArray.Builder builder)
+ {
+ switch (type)
+ {
+ case IArrayTypeSymbol array:
+ builder.Add((byte)array.NullableAnnotation);
+ Walk(array.ElementType, builder);
+ break;
+
+ case IPointerTypeSymbol pointer:
+ builder.Add((byte)pointer.NullableAnnotation);
+ Walk(pointer.PointedAtType, builder);
+ break;
+
+ case ITypeParameterSymbol typeParameter:
+ builder.Add((byte)typeParameter.NullableAnnotation);
+ break;
+
+ case IDynamicTypeSymbol:
+ builder.Add((byte)type.NullableAnnotation);
+ break;
+
+ case IFunctionPointerTypeSymbol functionPointer:
+ builder.Add((byte)functionPointer.NullableAnnotation);
+ var signature = functionPointer.Signature;
+ Walk(signature.ReturnType, builder);
+ foreach (var parameter in signature.Parameters)
+ {
+ Walk(parameter.Type, builder);
+ }
+
+ break;
+
+ case INamedTypeSymbol named:
+ // A nested type's containing type can itself carry annotations (Outer.Inner),
+ // so descend into it before the type's own annotation and its type arguments.
+ if (named.ContainingType is { } containingType)
+ {
+ Walk(containingType, builder);
+ }
+
+ builder.Add((byte)named.NullableAnnotation);
+ foreach (var argument in named.TypeArguments)
+ {
+ Walk(argument, builder);
+ }
+
+ break;
+
+ default:
+ builder.Add((byte)type.NullableAnnotation);
+ break;
+ }
+ }
+}
+
+///
+/// The part of a parameter that contributes to a member's identity: callers can never tell
+/// two members apart by anything else, so a difference here is an add/remove, not a modification.
+/// The is the binary ref kind: in and ref readonly
+/// collapse to because they share one calling convention; their source
+/// difference is carried by instead.
+///
+internal readonly record struct ParamKey(TypeRef Type, RefKind RefKind);
+
+///
+/// The part of a parameter that affects source equivalence only: its name (named
+/// arguments), its source-only modifiers (params, extension this, optional,
+/// ref readonly vs in), and the nullable annotations of its type. None of these change
+/// the binary calling convention.
+///
+internal readonly record struct SourceParam(string Name, ParamModifiers Modifiers, Nullability Nullability);
+
+///
+/// The identity of an API member: the tuple by which two members from different compilations are
+/// paired. A difference in identity is reported as an added/removed member, never a modification.
+///
+internal sealed record MemberIdentity(
+ ApiMemberKind Kind,
+ string Namespace,
+ TypeRef? ContainingType,
+ string Name,
+ int Arity,
+ EqArray Parameters);
+
+///
+/// The aspects of a type declaration observable to every consumer (source or binary):
+/// changing any breaks both equivalences. Shared by and
+/// .
+///
+internal sealed record CommonTypeAspects(MemberFlags Flags);
+
+/// The method aspects common to both equivalences (return type, static-ness, virtuality).
+internal sealed record CommonMethodAspects(TypeRef ReturnType, MemberFlags Flags);
+
+/// The field aspects common to both equivalences (type, static-ness, readonly, const-ness).
+internal sealed record CommonFieldAspects(TypeRef Type, MemberFlags Flags);
+
+/// The event aspects common to both equivalences (type, static-ness, virtuality).
+internal sealed record CommonEventAspects(TypeRef Type, MemberFlags Flags);
+
+///
+/// The projection of a member that defines source equivalence. Two members are
+/// source-equivalent exactly when their values are equal, so each case
+/// carries the aspects common to both equivalences plus only the source-only aspects of its kind.
+///
+internal abstract record SourceMember
+{
+ private SourceMember() { }
+
+ public sealed record Type(CommonTypeAspects Common) : SourceMember;
+
+ public sealed record Method(
+ CommonMethodAspects Common, Nullability ReturnNullability, EqArray Parameters) : SourceMember;
+
+ public sealed record Field(CommonFieldAspects Common, Nullability Nullability) : SourceMember;
+
+ public sealed record Event(CommonEventAspects Common, Nullability Nullability) : SourceMember;
+}
+
+///
+/// The projection of a member that defines binary equivalence. Two members are
+/// binary-equivalent exactly when their values are equal. It carries the
+/// same common aspects as plus only the binary-only aspects of its kind
+/// (the constant value baked into already-compiled consumers).
+///
+internal abstract record BinaryMember
+{
+ private BinaryMember() { }
+
+ public sealed record Type(CommonTypeAspects Common) : BinaryMember;
+
+ public sealed record Method(CommonMethodAspects Common) : BinaryMember;
+
+ public sealed record Field(CommonFieldAspects Common, string? ConstantValue) : BinaryMember;
+
+ public sealed record Event(CommonEventAspects Common) : BinaryMember;
+}
+
+///
+/// Structural representation of one externally-visible API member, decomposed into an
+/// (the pairing key) and two projections whose record equality
+/// is source/binary equivalence. The values may originate from different compilations
+/// (the project and the synthetic .cssig compilation).
+///
+internal sealed record ApiMember(MemberIdentity Identity, SourceMember Source, BinaryMember Binary)
+{
+ public static ApiMember From(ISymbol symbol)
+ {
+ var containingType = symbol.ContainingType is { } type ? TypeRef.From(type) : null;
+ var @namespace = containingType is null && symbol.ContainingNamespace is { IsGlobalNamespace: false } ns
+ ? ns.ToDisplayString()
+ : string.Empty;
+
+ // Every flag bit except a field's ReadOnly/HasConstantValue is reported generically by the
+ // symbol: types expose IsAbstract/IsSealed, members expose virtuality, and the rest are
+ // false for kinds they do not apply to.
+ var flags = FlagsFrom(symbol);
+
+ switch (symbol)
+ {
+ case INamedTypeSymbol named:
+ {
+ var identity = new MemberIdentity(
+ ApiMemberKind.Type, @namespace, containingType, named.Name, named.Arity, default);
+ var common = new CommonTypeAspects(flags);
+ return new ApiMember(identity, new SourceMember.Type(common), new BinaryMember.Type(common));
+ }
+
+ case IMethodSymbol method:
+ {
+ var keys = EqArray.From(
+ method.Parameters.Select(p => new ParamKey(TypeRef.From(p.Type), BinaryRefKind(p.RefKind))));
+ var parameters = EqArray.From(
+ method.Parameters.Select(p => new SourceParam(p.Name, ParamModifiersFrom(p), Nullability.Of(p.Type))));
+ var identity = new MemberIdentity(
+ ApiMemberKind.Method, @namespace, containingType, method.Name, method.Arity, keys);
+ var common = new CommonMethodAspects(
+ TypeRef.From(method.ReturnType),
+ flags | (method.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None));
+ return new ApiMember(
+ identity,
+ new SourceMember.Method(common, Nullability.Of(method.ReturnType), parameters),
+ new BinaryMember.Method(common));
+ }
+
+ case IFieldSymbol field:
+ {
+ var identity = new MemberIdentity(
+ ApiMemberKind.Field, @namespace, containingType, field.Name, Arity: 0, default);
+ flags |= (field.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None)
+ | (field.HasConstantValue ? MemberFlags.HasConstantValue : MemberFlags.None);
+ var common = new CommonFieldAspects(TypeRef.From(field.Type), flags);
+ var constant = field.HasConstantValue ? FormatConstant(field.ConstantValue) : null;
+ return new ApiMember(
+ identity,
+ new SourceMember.Field(common, Nullability.Of(field.Type)),
+ new BinaryMember.Field(common, constant));
+ }
+
+ case IEventSymbol @event:
+ {
+ var identity = new MemberIdentity(
+ ApiMemberKind.Event, @namespace, containingType, @event.Name, Arity: 0, default);
+ var common = new CommonEventAspects(TypeRef.From(@event.Type), flags);
+ return new ApiMember(
+ identity,
+ new SourceMember.Event(common, Nullability.Of(@event.Type)),
+ new BinaryMember.Event(common));
+ }
+
+ default:
+ // ApiSurface only ever yields types, methods, fields, and events (see
+ // IsTrackedApi/GetApiMembers). Any other kind cannot be credibly modeled or
+ // compared, so fail loudly rather than synthesize a meaningless member.
+ throw new ArgumentException(
+ $"Cannot build an API member from symbol kind '{symbol.Kind}'.", nameof(symbol));
+ }
+ }
+
+ private static MemberFlags FlagsFrom(ISymbol symbol)
+ => (symbol.IsStatic ? MemberFlags.Static : MemberFlags.None)
+ | (symbol.IsVirtual ? MemberFlags.Virtual : MemberFlags.None)
+ | (symbol.IsAbstract ? MemberFlags.Abstract : MemberFlags.None)
+ | (symbol.IsOverride ? MemberFlags.Override : MemberFlags.None)
+ | (symbol.IsSealed ? MemberFlags.Sealed : MemberFlags.None);
+
+ private static ParamModifiers ParamModifiersFrom(IParameterSymbol parameter)
+ => (parameter.IsParams ? ParamModifiers.Params : ParamModifiers.None)
+ | (parameter.IsThis ? ParamModifiers.This : ParamModifiers.None)
+ | (parameter.HasExplicitDefaultValue ? ParamModifiers.Optional : ParamModifiers.None)
+ | (parameter.RefKind == RefReadOnlyParameter ? ParamModifiers.RefReadOnly : ParamModifiers.None);
+
+ // RefKind.RefReadOnlyParameter (C# 12) is not defined in the Roslyn baseline this analyzer
+ // compiles against, but the host compiler reports it at runtime. Reference it by its numeric
+ // value so the analyzer keeps targeting the older Roslyn version.
+ private const RefKind RefReadOnlyParameter = (RefKind)4;
+
+ // `in` and `ref readonly` parameters share the same binary calling convention (both an
+ // `in`-flagged byref with a `modreq(InAttribute)`); `ref readonly` only adds a source-level
+ // `RequiresLocationAttribute`. Identity therefore pairs them, and the source difference is
+ // carried by ParamModifiers.RefReadOnly so it surfaces as a source-only modification.
+ private static RefKind BinaryRefKind(RefKind refKind)
+ => refKind == RefReadOnlyParameter ? RefKind.In : refKind;
+
+ private static string FormatConstant(object? value)
+ => value is null ? "null" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null";
+}
diff --git a/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj b/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj
new file mode 100644
index 0000000..25818b7
--- /dev/null
+++ b/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj
@@ -0,0 +1,20 @@
+
+
+
+ netstandard2.0
+ enable
+ latest
+ StaticCs.CsSig.Analyzers
+ true
+
+
+ false
+
+
+
+
+
+
+
+
+
diff --git a/src/CsSig/Analyzer/SymbolVisibility.cs b/src/CsSig/Analyzer/SymbolVisibility.cs
new file mode 100644
index 0000000..7b2f82e
--- /dev/null
+++ b/src/CsSig/Analyzer/SymbolVisibility.cs
@@ -0,0 +1,64 @@
+using Microsoft.CodeAnalysis;
+
+namespace CsSig;
+
+internal enum SymbolVisibility
+{
+ Public = 0,
+ Internal = 1,
+ Private = 2,
+}
+
+internal static class SymbolVisibilityExtensions
+{
+ ///
+ /// Computes the effective visibility of a symbol, taking its containing types into account.
+ /// Ported from Roslyn's ISymbolExtensions.GetResultantVisibility
+ /// (dotnet/roslyn: src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Extensions/Symbols/ISymbolExtensions.cs).
+ ///
+ public static SymbolVisibility GetResultantVisibility(this ISymbol symbol)
+ {
+ // Start by assuming it's visible.
+ var visibility = SymbolVisibility.Public;
+
+ switch (symbol.Kind)
+ {
+ case SymbolKind.Alias:
+ // Aliases are only visible in the file they were declared in.
+ return SymbolVisibility.Private;
+
+ case SymbolKind.Parameter:
+ // Parameters are only as visible as their containing symbol.
+ return GetResultantVisibility(symbol.ContainingSymbol);
+
+ case SymbolKind.TypeParameter:
+ // Type parameters are private.
+ return SymbolVisibility.Private;
+ }
+
+ ISymbol? current = symbol;
+ while (current != null && current.Kind != SymbolKind.Namespace)
+ {
+ switch (current.DeclaredAccessibility)
+ {
+ // If we see anything private, then the symbol is private.
+ case Accessibility.NotApplicable:
+ case Accessibility.Private:
+ return SymbolVisibility.Private;
+
+ // If we see anything internal, then knock it down from public to internal.
+ case Accessibility.Internal:
+ case Accessibility.ProtectedAndInternal:
+ visibility = SymbolVisibility.Internal;
+ break;
+
+ // For anything else (Public, Protected, ProtectedOrInternal), the
+ // symbol stays at the level we've gotten so far.
+ }
+
+ current = current.ContainingSymbol;
+ }
+
+ return visibility;
+ }
+}
diff --git a/src/CsSig/Analyzer/build/StaticCS.CsSig.props b/src/CsSig/Analyzer/build/StaticCS.CsSig.props
new file mode 100644
index 0000000..2604cf4
--- /dev/null
+++ b/src/CsSig/Analyzer/build/StaticCS.CsSig.props
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+ Both
+
+
+
+
+
+
diff --git a/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs b/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs
new file mode 100644
index 0000000..752256c
--- /dev/null
+++ b/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs
@@ -0,0 +1,451 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Composition;
+using System.IO;
+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.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace CsSig;
+
+///
+/// Offers a fix for CSSIG002 (a public member missing from the .cssig files) that
+/// (re)generates a signature file from the project's current public API via .
+///
+///
+///
+/// Every .cssig file "owns" a set of top-level types — the ones it declares. Adding a missing
+/// member is therefore the same operation regardless of where it lands: regenerate the owning file
+/// from the project's current surface (which now includes the member). Because regeneration rewrites
+/// the whole file from symbols, it is idempotent and automatically resolves every outstanding
+/// CSSIG002 for the types that file owns at once.
+///
+///
+/// If a file already declares the member's (top-level) type, the member is added there. If no file
+/// declares it, the fix offers two destinations: the default PublicAPI.cssig, or a
+/// per-type <TypeName>.cssig file.
+///
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CsSigCodeFixProvider)), Shared]
+public sealed class CsSigCodeFixProvider : CodeFixProvider
+{
+ internal const string Extension = ".cssig";
+ internal const string DefaultFileName = "PublicAPI.cssig";
+
+ // Equivalence keys double as the FixAll destination strategy for *undeclared* types.
+ public const string ToPublicApiKey = "CsSig.AddToPublicApi";
+ public const string ToTypeFileKey = "CsSig.AddToTypeFile";
+
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DiagId.MissingFromSignature.ToIdString());
+
+ public override FixAllProvider GetFixAllProvider() => CsSigFixAllProvider.Instance;
+
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var document = context.Document;
+ var project = document.Project;
+
+ var compilation = await project.GetCompilationAsync(context.CancellationToken).ConfigureAwait(false);
+ var model = await document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ var root = await document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (compilation is null || model is null || root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics[0];
+ var symbol = ResolveMember(model, root, diagnostic.Location.SourceSpan, context.CancellationToken);
+ if (symbol is null)
+ {
+ return;
+ }
+
+ var topLevel = TopLevelType(symbol);
+ if (topLevel is null)
+ {
+ return;
+ }
+
+ var key = CsSigWriter.TopLevelKey(topLevel);
+ var index = await SignatureIndex.BuildAsync(project, context.CancellationToken).ConfigureAwait(false);
+
+ if (index.OwnerOf(key) is { } owner)
+ {
+ // The type is already declared in a .cssig file: regenerate that file.
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ $"Add missing API to '{owner.Name}'",
+ ct => RegenerateOwningFileAsync(project, owner, index, key, ct),
+ equivalenceKey: ToPublicApiKey),
+ diagnostic);
+ return;
+ }
+
+ // The type is not declared anywhere: offer PublicAPI.cssig (default) or .cssig.
+ var perTypeName = topLevel.Name + Extension;
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ $"Add API to '{DefaultFileName}'",
+ ct => AddTypeToNamedFileAsync(project, DefaultFileName, index, key, ct),
+ equivalenceKey: ToPublicApiKey),
+ diagnostic);
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ $"Add API to '{perTypeName}'",
+ ct => AddTypeToNamedFileAsync(project, perTypeName, index, key, ct),
+ equivalenceKey: ToTypeFileKey),
+ diagnostic);
+ }
+
+ /// Regenerates the file that already owns from the project's
+ /// current surface.
+ private static async Task RegenerateOwningFileAsync(
+ Project project, TextDocument owner, SignatureIndex index, string key, CancellationToken ct)
+ {
+ var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly;
+ if (assembly is null)
+ {
+ return project.Solution;
+ }
+
+ var keys = new HashSet(index.KeysOwnedBy(owner.Id)) { key };
+ var text = CsSigWriter.Write(assembly, keys);
+ return project.Solution.WithAdditionalDocumentText(owner.Id, SourceText.From(text));
+ }
+
+ /// Adds 's type to the file named ,
+ /// creating it if necessary, otherwise regenerating it with the type included.
+ private static async Task AddTypeToNamedFileAsync(
+ Project project, string fileName, SignatureIndex index, string key, CancellationToken ct)
+ {
+ var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly;
+ if (assembly is null)
+ {
+ return project.Solution;
+ }
+
+ var existing = index.DocumentNamed(fileName);
+ if (existing is not null)
+ {
+ var keys = new HashSet(index.KeysOwnedBy(existing.Id)) { key };
+ var updated = CsSigWriter.Write(assembly, keys);
+ return project.Solution.WithAdditionalDocumentText(existing.Id, SourceText.From(updated));
+ }
+
+ var text = CsSigWriter.Write(assembly, new HashSet { key });
+ return project
+ .AddAdditionalDocument(fileName, SourceText.From(text), filePath: FilePathFor(project, fileName))
+ .Project.Solution;
+ }
+
+ private static string FilePathFor(Project project, string fileName)
+ {
+ var dir = project.FilePath is { } p ? Path.GetDirectoryName(p) : null;
+ if (dir is null
+ && project.AdditionalDocuments.Select(d => d.FilePath).FirstOrDefault(d => d is not null) is { } existing)
+ {
+ dir = Path.GetDirectoryName(existing);
+ }
+
+ return dir is null ? fileName : Path.Combine(dir, fileName);
+ }
+
+ /// The outermost containing type of — the top-level type that
+ /// a signature file declares. Property/event accessors are normalized to their owning member.
+ private static INamedTypeSymbol? TopLevelType(ISymbol symbol)
+ {
+ var member = symbol is IMethodSymbol { AssociatedSymbol: { } associated }
+ && associated is IPropertySymbol or IEventSymbol
+ ? associated
+ : symbol;
+
+ var type = member as INamedTypeSymbol ?? member.ContainingType;
+ if (type is null)
+ {
+ return null;
+ }
+
+ while (type.ContainingType is { } outer)
+ {
+ type = outer;
+ }
+
+ return type;
+ }
+
+ /// Resolves the declared symbol the CSSIG002 diagnostic refers to, walking up from the
+ /// node at until a member or type symbol is found.
+ private static ISymbol? ResolveMember(SemanticModel model, SyntaxNode root, TextSpan span, CancellationToken ct)
+ {
+ var node = root.FindNode(span, getInnermostNodeForTie: true);
+ for (var current = node; current is not null; current = current.Parent)
+ {
+ var declared = model.GetDeclaredSymbol(current, ct);
+ if (declared is INamedTypeSymbol or IMethodSymbol or IPropertySymbol or IEventSymbol or IFieldSymbol)
+ {
+ return declared;
+ }
+
+ if (current is BaseTypeDeclarationSyntax)
+ {
+ break;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsSignatureFile(TextDocument doc)
+ => (doc.FilePath ?? doc.Name).EndsWith(Extension, System.StringComparison.OrdinalIgnoreCase);
+
+ /// The keys (see ) of the top-level types declared
+ /// in a .cssig document.
+ private static async Task> TopLevelKeysAsync(TextDocument doc, CancellationToken ct)
+ {
+ var keys = new HashSet();
+ var text = await doc.GetTextAsync(ct).ConfigureAwait(false);
+ var root = CSharpSyntaxTree.ParseText(text, cancellationToken: ct).GetRoot(ct);
+
+ foreach (var type in root.DescendantNodes().OfType())
+ {
+ // Top-level = not nested inside another type declaration.
+ if (type.Ancestors().OfType().Any())
+ {
+ continue;
+ }
+
+ keys.Add(SyntaxTopLevelKey(type));
+ }
+
+ return keys;
+ }
+
+ private static string SyntaxTopLevelKey(BaseTypeDeclarationSyntax type)
+ {
+ var ns = NamespaceName(type);
+ var arity = type is TypeDeclarationSyntax { TypeParameterList: { } list } ? list.Parameters.Count : 0;
+ var name = arity > 0 ? type.Identifier.ValueText + "`" + arity : type.Identifier.ValueText;
+ return ns.Length > 0 ? ns + "." + name : name;
+ }
+
+ private static string NamespaceName(SyntaxNode node)
+ {
+ var names = new List();
+ for (var ancestor = node.Parent; ancestor is not null; ancestor = ancestor.Parent)
+ {
+ if (ancestor is BaseNamespaceDeclarationSyntax ns)
+ {
+ names.Add(ns.Name.ToString());
+ }
+ }
+
+ names.Reverse();
+ return string.Join(".", names);
+ }
+
+ /// An index of the project's .cssig additional documents and the top-level types
+ /// each one declares.
+ internal sealed class SignatureIndex
+ {
+ private readonly Dictionary _ownerByKey;
+ private readonly Dictionary> _keysByDocument;
+ private readonly List _documents;
+
+ private SignatureIndex(
+ Dictionary ownerByKey,
+ Dictionary> keysByDocument,
+ List documents)
+ {
+ _ownerByKey = ownerByKey;
+ _keysByDocument = keysByDocument;
+ _documents = documents;
+ }
+
+ public static async Task BuildAsync(Project project, CancellationToken ct)
+ {
+ var ownerByKey = new Dictionary();
+ var keysByDocument = new Dictionary>();
+ var documents = new List();
+
+ foreach (var doc in project.AdditionalDocuments)
+ {
+ if (!IsSignatureFile(doc))
+ {
+ continue;
+ }
+
+ documents.Add(doc);
+ var keys = await TopLevelKeysAsync(doc, ct).ConfigureAwait(false);
+ keysByDocument[doc.Id] = keys;
+ foreach (var key in keys)
+ {
+ ownerByKey[key] = doc;
+ }
+ }
+
+ return new SignatureIndex(ownerByKey, keysByDocument, documents);
+ }
+
+ public TextDocument? OwnerOf(string key) => _ownerByKey.TryGetValue(key, out var doc) ? doc : null;
+
+ public IEnumerable KeysOwnedBy(DocumentId id)
+ => _keysByDocument.TryGetValue(id, out var keys) ? keys : Enumerable.Empty();
+
+ public TextDocument? DocumentNamed(string name)
+ => _documents.FirstOrDefault(d => string.Equals(d.Name, name, System.StringComparison.OrdinalIgnoreCase));
+ }
+
+ private sealed class CsSigFixAllProvider : FixAllProvider
+ {
+ public static readonly CsSigFixAllProvider Instance = new();
+
+ public override async Task GetFixAsync(FixAllContext fixAllContext)
+ {
+ var diagnostics = await GatherAsync(fixAllContext).ConfigureAwait(false);
+ if (diagnostics.IsEmpty)
+ {
+ return null;
+ }
+
+ var strategy = fixAllContext.CodeActionEquivalenceKey ?? ToPublicApiKey;
+ return CodeAction.Create(
+ "Add missing API to .cssig files",
+ ct => ApplyAsync(fixAllContext.Solution, fixAllContext.Project, diagnostics, strategy, ct),
+ equivalenceKey: strategy);
+ }
+
+ private static async Task> GatherAsync(FixAllContext context)
+ {
+ switch (context.Scope)
+ {
+ case FixAllScope.Document when context.Document is { } document:
+ return await context.GetDocumentDiagnosticsAsync(document).ConfigureAwait(false);
+ case FixAllScope.Project:
+ return await context.GetAllDiagnosticsAsync(context.Project).ConfigureAwait(false);
+ case FixAllScope.Solution:
+ var all = ImmutableArray.CreateBuilder();
+ foreach (var project in context.Solution.Projects)
+ {
+ all.AddRange(await context.GetAllDiagnosticsAsync(project).ConfigureAwait(false));
+ }
+
+ return all.ToImmutable();
+ default:
+ return ImmutableArray.Empty;
+ }
+ }
+
+ ///
+ /// Regenerates every signature file touched by the batch. Each missing member is routed to the
+ /// file that owns its top-level type (or, for an undeclared type, to PublicAPI.cssig or a
+ /// per-type file per ); each touched file is then regenerated once
+ /// from the project surface with the full set of types it should own.
+ ///
+ private static async Task ApplyAsync(
+ Solution solution, Project project, ImmutableArray diagnostics, string strategy, CancellationToken ct)
+ {
+ var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly;
+ if (assembly is null)
+ {
+ return solution;
+ }
+
+ var index = await SignatureIndex.BuildAsync(project, ct).ConfigureAwait(false);
+
+ // fileName -> the set of top-level keys that file should own after the fix.
+ var plan = new Dictionary>(System.StringComparer.OrdinalIgnoreCase);
+ var existingByName = new Dictionary(System.StringComparer.OrdinalIgnoreCase);
+
+ foreach (var diagnostic in diagnostics)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var key = await ResolveKeyAsync(solution, diagnostic, ct).ConfigureAwait(false);
+ if (key is null)
+ {
+ continue;
+ }
+
+ var target = index.OwnerOf(key);
+ string fileName;
+ if (target is not null)
+ {
+ fileName = target.Name;
+ }
+ else
+ {
+ fileName = strategy == ToTypeFileKey
+ ? TypeNameFromKey(key) + Extension
+ : DefaultFileName;
+ target = index.DocumentNamed(fileName);
+ }
+
+ if (!plan.TryGetValue(fileName, out var keys))
+ {
+ keys = target is not null
+ ? new HashSet(index.KeysOwnedBy(target.Id))
+ : new HashSet();
+ plan[fileName] = keys;
+ if (target is not null)
+ {
+ existingByName[fileName] = target;
+ }
+ }
+
+ keys.Add(key);
+ }
+
+ foreach (var entry in plan)
+ {
+ var text = SourceText.From(CsSigWriter.Write(assembly, entry.Value));
+ if (existingByName.TryGetValue(entry.Key, out var doc))
+ {
+ solution = solution.WithAdditionalDocumentText(doc.Id, text);
+ }
+ else
+ {
+ var id = DocumentId.CreateNewId(project.Id);
+ solution = solution.AddAdditionalDocument(
+ id, entry.Key, text, filePath: FilePathFor(project, entry.Key));
+ }
+ }
+
+ return solution;
+ }
+
+ private static async Task ResolveKeyAsync(Solution solution, Diagnostic diagnostic, CancellationToken ct)
+ {
+ if (diagnostic.Location.SourceTree is not { } tree
+ || solution.GetDocument(tree) is not { } document)
+ {
+ return null;
+ }
+
+ var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false);
+ var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false);
+ if (model is null || root is null)
+ {
+ return null;
+ }
+
+ var symbol = ResolveMember(model, root, diagnostic.Location.SourceSpan, ct);
+ var topLevel = symbol is null ? null : TopLevelType(symbol);
+ return topLevel is null ? null : CsSigWriter.TopLevelKey(topLevel);
+ }
+
+ private static string TypeNameFromKey(string key)
+ {
+ var lastDot = key.LastIndexOf('.');
+ var name = lastDot >= 0 ? key.Substring(lastDot + 1) : key;
+ var backtick = name.IndexOf('`');
+ return backtick >= 0 ? name.Substring(0, backtick) : name;
+ }
+ }
+}
diff --git a/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj b/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj
new file mode 100644
index 0000000..7df923a
--- /dev/null
+++ b/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj
@@ -0,0 +1,48 @@
+
+
+
+ netstandard2.0
+ enable
+ latest
+ StaticCs.CsSig.CodeFixes
+ true
+
+
+
+
+ StaticCS.CsSig
+ 0.1.0
+ agocke
+ true
+ BSD-3-Clause
+ https://github.com/agocke/static-cs
+ An analyzer that checks a project's public API surface against C# signature (.cssig) files, with a code fix that generates them.
+ README.md
+
+ false
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CsSig/vscode/README.md b/src/CsSig/vscode/README.md
new file mode 100644
index 0000000..8aa0be4
--- /dev/null
+++ b/src/CsSig/vscode/README.md
@@ -0,0 +1,23 @@
+# C# Signature (`.cssig`) — VS Code support
+
+Syntax highlighting and basic editor configuration (brackets, comments, auto-closing pairs) for
+`.cssig` files used by the [CsSig analyzer](../../../src/CsSig).
+
+A `.cssig` file is ordinary C# describing a project's public API surface, with member declarations
+written without bodies.
+
+## Install (local development)
+
+Copy or symlink this folder into your VS Code extensions directory:
+
+```sh
+ln -s "$(pwd)" ~/.vscode/extensions/static-cs.cssig-0.1.0
+```
+
+Then reload VS Code. Files ending in `.cssig` will be highlighted using the `source.cssig` grammar.
+
+## Contents
+
+- `package.json` — language + grammar contribution.
+- `language-configuration.json` — comments, brackets, auto-closing/surrounding pairs.
+- `syntaxes/cssig.tmLanguage.json` — the TextMate grammar (`scopeName: source.cssig`).
diff --git a/src/CsSig/vscode/language-configuration.json b/src/CsSig/vscode/language-configuration.json
new file mode 100644
index 0000000..d938d54
--- /dev/null
+++ b/src/CsSig/vscode/language-configuration.json
@@ -0,0 +1,71 @@
+{
+ "comments": {
+ "lineComment": "//",
+ "blockComment": [
+ "/*",
+ "*/"
+ ]
+ },
+ "brackets": [
+ [
+ "{",
+ "}"
+ ],
+ [
+ "[",
+ "]"
+ ],
+ [
+ "(",
+ ")"
+ ],
+ [
+ "<",
+ ">"
+ ]
+ ],
+ "autoClosingPairs": [
+ {
+ "open": "{",
+ "close": "}"
+ },
+ {
+ "open": "[",
+ "close": "]"
+ },
+ {
+ "open": "(",
+ "close": ")"
+ },
+ {
+ "open": "\"",
+ "close": "\"",
+ "notIn": [
+ "string",
+ "comment"
+ ]
+ }
+ ],
+ "surroundingPairs": [
+ [
+ "{",
+ "}"
+ ],
+ [
+ "[",
+ "]"
+ ],
+ [
+ "(",
+ ")"
+ ],
+ [
+ "<",
+ ">"
+ ],
+ [
+ "\"",
+ "\""
+ ]
+ ]
+}
diff --git a/src/CsSig/vscode/package.json b/src/CsSig/vscode/package.json
new file mode 100644
index 0000000..51331ea
--- /dev/null
+++ b/src/CsSig/vscode/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "cssig",
+ "displayName": "C# Signature (.cssig)",
+ "description": "Syntax highlighting for C# signature (.cssig) files used by the CsSig analyzer.",
+ "version": "0.1.0",
+ "publisher": "static-cs",
+ "license": "BSD-3-Clause",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/agocke/static-cs"
+ },
+ "engines": {
+ "vscode": "^1.60.0"
+ },
+ "categories": [
+ "Programming Languages"
+ ],
+ "contributes": {
+ "languages": [
+ {
+ "id": "cssig",
+ "aliases": [
+ "C# Signature",
+ "cssig"
+ ],
+ "extensions": [
+ ".cssig"
+ ],
+ "configuration": "./language-configuration.json"
+ }
+ ],
+ "grammars": [
+ {
+ "language": "cssig",
+ "scopeName": "source.cssig",
+ "path": "./syntaxes/cssig.tmLanguage.json"
+ }
+ ]
+ }
+}
diff --git a/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json b/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json
new file mode 100644
index 0000000..e3ab77c
--- /dev/null
+++ b/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json
@@ -0,0 +1,229 @@
+{
+ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
+ "name": "C# Signature",
+ "scopeName": "source.cssig",
+ "patterns": [
+ {
+ "include": "#comment"
+ },
+ {
+ "include": "#directive"
+ },
+ {
+ "include": "#attribute"
+ },
+ {
+ "include": "#namespace"
+ },
+ {
+ "include": "#type-declaration"
+ },
+ {
+ "include": "#keyword"
+ },
+ {
+ "include": "#storage-type"
+ },
+ {
+ "include": "#string"
+ },
+ {
+ "include": "#char"
+ },
+ {
+ "include": "#number"
+ },
+ {
+ "include": "#punctuation"
+ },
+ {
+ "include": "#identifier"
+ }
+ ],
+ "repository": {
+ "comment": {
+ "patterns": [
+ {
+ "name": "comment.block.documentation.cssig",
+ "begin": "///",
+ "end": "$"
+ },
+ {
+ "name": "comment.line.double-slash.cssig",
+ "begin": "//",
+ "end": "$"
+ },
+ {
+ "name": "comment.block.cssig",
+ "begin": "/\\*",
+ "end": "\\*/"
+ }
+ ]
+ },
+ "directive": {
+ "name": "meta.preprocessor.cssig",
+ "begin": "^\\s*#\\s*(nullable|define|undef|if|elif|else|endif|region|endregion|pragma|warning|error|line)\\b",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.control.directive.cssig"
+ }
+ },
+ "end": "$"
+ },
+ "attribute": {
+ "name": "meta.attribute.cssig",
+ "begin": "(\\[)(?=\\s*[A-Za-z_])",
+ "beginCaptures": {
+ "1": {
+ "name": "punctuation.section.attribute.begin.cssig"
+ }
+ },
+ "end": "(\\])",
+ "endCaptures": {
+ "1": {
+ "name": "punctuation.section.attribute.end.cssig"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#comment"
+ },
+ {
+ "match": "[A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)*",
+ "name": "entity.name.type.attribute.cssig"
+ },
+ {
+ "include": "#string"
+ },
+ {
+ "include": "#number"
+ },
+ {
+ "include": "#punctuation"
+ }
+ ]
+ },
+ "namespace": {
+ "match": "\\b(namespace)\\s+([A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)*)",
+ "captures": {
+ "1": {
+ "name": "keyword.other.namespace.cssig"
+ },
+ "2": {
+ "name": "entity.name.type.namespace.cssig"
+ }
+ }
+ },
+ "type-declaration": {
+ "match": "\\b(class|struct|interface|enum|record|delegate)\\b(?:\\s+(class|struct))?\\s+([A-Za-z_][A-Za-z0-9_]*)",
+ "captures": {
+ "1": {
+ "name": "keyword.other.cssig storage.type.cssig"
+ },
+ "2": {
+ "name": "keyword.other.cssig storage.type.cssig"
+ },
+ "3": {
+ "name": "entity.name.type.cssig"
+ }
+ }
+ },
+ "keyword": {
+ "patterns": [
+ {
+ "name": "storage.modifier.cssig",
+ "match": "\\b(public|private|protected|internal|static|abstract|sealed|virtual|override|readonly|const|extern|partial|new|unsafe|volatile|async|required|file|in|out|ref|params)\\b"
+ },
+ {
+ "name": "keyword.other.cssig",
+ "match": "\\b(using|namespace|class|struct|interface|enum|record|delegate|event|where|operator|implicit|explicit|get|set|init|add|remove|this|base|global|nameof|typeof|default)\\b"
+ }
+ ]
+ },
+ "storage-type": {
+ "name": "storage.type.builtin.cssig keyword.type.cssig",
+ "match": "\\b(void|bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|string|object|nint|nuint|dynamic|var)\\b"
+ },
+ "string": {
+ "patterns": [
+ {
+ "name": "string.quoted.double.verbatim.cssig",
+ "begin": "@\"",
+ "end": "\"(?!\")",
+ "patterns": [
+ {
+ "match": "\"\"",
+ "name": "constant.character.escape.cssig"
+ }
+ ]
+ },
+ {
+ "name": "string.quoted.double.cssig",
+ "begin": "\\$?\"",
+ "end": "\"",
+ "patterns": [
+ {
+ "name": "constant.character.escape.cssig",
+ "match": "\\\\."
+ }
+ ]
+ }
+ ]
+ },
+ "char": {
+ "name": "string.quoted.single.cssig",
+ "begin": "'",
+ "end": "'",
+ "patterns": [
+ {
+ "name": "constant.character.escape.cssig",
+ "match": "\\\\."
+ }
+ ]
+ },
+ "number": {
+ "name": "constant.numeric.cssig",
+ "match": "\\b(0[xX][0-9a-fA-F_]+|0[bB][01_]+|[0-9_]+(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([uUlLfFdDmM]{0,2})\\b"
+ },
+ "punctuation": {
+ "patterns": [
+ {
+ "name": "punctuation.terminator.statement.cssig",
+ "match": ";"
+ },
+ {
+ "name": "punctuation.separator.cssig",
+ "match": "[,.:]"
+ },
+ {
+ "name": "punctuation.section.brackets.cssig",
+ "match": "[\\[\\]{}()]"
+ },
+ {
+ "name": "keyword.operator.cssig",
+ "match": "[<>=&|+\\-*/%!?~]"
+ }
+ ]
+ },
+ "identifier": {
+ "patterns": [
+ {
+ "match": "([A-Za-z_][A-Za-z0-9_]*)\\s*(?=<|\\()",
+ "captures": {
+ "1": {
+ "name": "entity.name.function.cssig"
+ }
+ }
+ },
+ {
+ "match": "\\b([A-Z][A-Za-z0-9_]*)\\b",
+ "name": "entity.name.type.cssig"
+ },
+ {
+ "name": "variable.other.cssig",
+ "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b"
+ }
+ ]
+ }
+ }
+}
diff --git a/static-cs.sln b/static-cs.sln
index c9c99d9..4d615a9 100644
--- a/static-cs.sln
+++ b/static-cs.sln
@@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.Result", "src\Resu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IndentingBuilder", "src\IndentingBuilder\IndentingBuilder.csproj", "{3EBB37BC-0309-4562-9785-0CC2D9D23944}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.CsSig.Analyzers", "src\CsSig\Analyzer\StaticCs.CsSig.Analyzers.csproj", "{E7295DA3-FB09-4894-8C72-0E803A43A421}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.CsSig.CodeFixes", "src\CsSig\CodeFixes\StaticCs.CsSig.CodeFixes.csproj", "{1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -129,6 +133,30 @@ Global
{3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x64.Build.0 = Release|Any CPU
{3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x86.ActiveCfg = Release|Any CPU
{3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x86.Build.0 = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x64.Build.0 = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x86.Build.0 = Debug|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x64.ActiveCfg = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x64.Build.0 = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x86.ActiveCfg = Release|Any CPU
+ {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x86.Build.0 = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x64.Build.0 = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x86.Build.0 = Debug|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x64.ActiveCfg = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x64.Build.0 = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x86.ActiveCfg = Release|Any CPU
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -142,5 +170,7 @@ Global
{B29FFDB1-7006-42FA-9647-3101CDFB364F} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426}
{8FF14261-BA9C-487F-8581-3F7D978EA772} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426}
{3EBB37BC-0309-4562-9785-0CC2D9D23944} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426}
+ {E7295DA3-FB09-4894-8C72-0E803A43A421} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426}
+ {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426}
EndGlobalSection
EndGlobal
diff --git a/test/test/CsSigTests.cs b/test/test/CsSigTests.cs
new file mode 100644
index 0000000..ba17761
--- /dev/null
+++ b/test/test/CsSigTests.cs
@@ -0,0 +1,1181 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CsSig;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Text;
+using Xunit;
+
+namespace StaticCs.Tests;
+
+public class CsSigTests
+{
+ [Fact]
+ public async Task ExactMatchProducesNoDiagnostics()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M(string s) => s.Length;
+ public string Name { get; set; } = "";
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M(string s);
+ public string Name { get; set; }
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ Assert.Empty(diagnostics);
+ }
+
+ [Fact]
+ public async Task SignatureMissingFromProjectReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M();
+ public int Extra();
+ }
+ """;
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG001", diagnostic.Id);
+ Assert.Contains("Extra", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task PublicMemberMissingFromSignatureReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int Extra() => 1;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M();
+ }
+ """;
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG002", diagnostic.Id);
+ Assert.Contains("Extra", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task InternalAndPrivateMembersAreIgnored()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ internal int Hidden() => 1;
+ private int Secret() => 2;
+ }
+ internal class NotPublic
+ {
+ public int Whatever() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M();
+ }
+ """;
+ Assert.Empty(await RunAsync(source, sig));
+ }
+
+ [Fact]
+ public async Task ChangedReturnTypeReportsMismatch()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public string M();
+ }
+ """;
+ // The return type is a common aspect, not part of the member's identity, so the members
+ // pair up and the difference is one mismatch (breaking both equivalences), not add/remove.
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ Assert.Contains("source and binary", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task MissingTypeIsReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M();
+ }
+ public class Missing
+ {
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ // The missing type and its implicit constructor are both absent from the project.
+ Assert.All(diagnostics, d => Assert.Equal("CSSIG001", d.Id));
+ Assert.Contains(diagnostics, d => d.GetMessage().Contains("N.Missing"));
+ }
+
+ [Fact]
+ public async Task NoSignatureFilesMeansNoEnforcement()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int Extra() => 1;
+ }
+ """;
+ Assert.Empty(await RunAsync(source));
+ }
+
+ [Fact]
+ public async Task EnumExactMatch()
+ {
+ var source = """
+ namespace N;
+ public enum Color { Red, Green, Blue }
+ """;
+ var sig = """
+ namespace N;
+ public enum Color { Red, Green, Blue }
+ """;
+ Assert.Empty(await RunAsync(source, sig));
+ }
+
+ [Fact]
+ public async Task MissingEnumMemberReported()
+ {
+ var source = """
+ namespace N;
+ public enum Color { Red, Green, Blue }
+ """;
+ var sig = """
+ namespace N;
+ public enum Color { Red, Green }
+ """;
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG002", diagnostic.Id);
+ Assert.Contains("Blue", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task SyntaxErrorInSignatureFileReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ }
+ """;
+ var sig = """
+ namespace N
+ public class C {
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ Assert.Contains(diagnostics, d => d.Id == "CSSIG003");
+ }
+
+ [Fact]
+ public async Task PartialInSignatureFileReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public partial class C
+ {
+ public partial int M();
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004"));
+ }
+
+ [Fact]
+ public async Task MemberBodyInSignatureFileReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int P { get; }
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int P { get { return 0; } }
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ // The expression body on M and the block body on P's getter are both rejected.
+ Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004"));
+ }
+
+ [Fact]
+ public async Task ModifiersThatDoNotAffectTheSignatureAreReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int F;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public async int M();
+ public volatile int F;
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ // 'async' (method) and 'volatile' (field) are invisible to the comparison: both rejected,
+ // and the signatures still match so there are no missing/mismatch diagnostics.
+ Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004"));
+ Assert.DoesNotContain(diagnostics, d => d.Id is "CSSIG001" or "CSSIG002" or "CSSIG005");
+ }
+
+ [Fact]
+ public async Task ModifiersThatAffectVirtualityAreAllowedAndCompared()
+ {
+ var source = """
+ namespace N;
+ public abstract class C
+ {
+ public virtual int M() => 0;
+ public abstract int N();
+ }
+ """;
+ var sig = """
+ namespace N;
+ public abstract class C
+ {
+ public virtual int M();
+ public abstract int N();
+ }
+ """;
+ // 'abstract' (type) and 'virtual'/'abstract' (members) affect both equivalences, so they
+ // are allowed and, matching the project, produce no diagnostics.
+ Assert.Empty(await RunAsync(source, sig));
+ }
+
+ [Fact]
+ public async Task VirtualityMismatchReportedInBothEquivalences()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public virtual int M();
+ }
+ """;
+ // Virtuality is a common aspect: declaring 'virtual' when the project's member is not
+ // breaks both equivalences.
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ Assert.Contains("source and binary", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task ParameterNameChangeBreaksOnlySourceEquivalence()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M(string s) => s.Length;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M(string name);
+ }
+ """;
+ // A renamed parameter changes named-argument call sites (source) but not the binary
+ // calling convention.
+ var underBoth = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", underBoth.Id);
+ Assert.Contains("breaks source equivalence", underBoth.GetMessage());
+
+ Assert.Empty(await RunWithEquivalenceAsync(source, "Binary", sig));
+ }
+
+ [Fact]
+ public async Task InVsRefReadonlyBreaksOnlySourceEquivalence()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M(ref readonly int x) => x;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M(in int x);
+ }
+ """;
+ // `in` and `ref readonly` share a binary calling convention (both a modreq(InAttribute)
+ // byref), so they differ for source equivalence (call-site rules) but are binary-equivalent
+ // -- a single source-only modification, not an add/remove.
+ var underBoth = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", underBoth.Id);
+ Assert.Contains("breaks source equivalence", underBoth.GetMessage());
+
+ Assert.Empty(await RunWithEquivalenceAsync(source, "Binary", sig));
+ }
+
+ [Fact]
+ public async Task ConstValueChangeBreaksOnlyBinaryEquivalence()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public const int X = 1;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public const int X = 2;
+ }
+ """;
+ // A changed const value is baked into already-compiled consumers (binary) but a source
+ // recompile picks up the new value.
+ var underBinary = Assert.Single(await RunWithEquivalenceAsync(source, "Binary", sig));
+ Assert.Equal("CSSIG005", underBinary.Id);
+ Assert.Contains("breaks binary equivalence", underBinary.GetMessage());
+
+ Assert.Empty(await RunWithEquivalenceAsync(source, "Source", sig));
+ }
+
+ [Fact]
+ public async Task FunctionPointerTypesAreComparedStructurally()
+ {
+ // The project source needs 'unsafe' (real C#); the .cssig declares the same member
+ // without it, since 'unsafe' has no signature impact and is rejected in .cssig.
+ var source = """
+ namespace N;
+ public unsafe class C
+ {
+ public delegate* F;
+ }
+ """;
+ var match = """
+ namespace N;
+ public class C
+ {
+ public delegate* F;
+ }
+ """;
+ // Identical function-pointer signatures are equivalent.
+ Assert.Empty(await RunAsync(source, match));
+
+ // A differing function-pointer parameter type (int vs long) must be detected
+ // structurally, not by a string blob.
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public delegate* F;
+ }
+ """;
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ Assert.Contains("source and binary", diagnostic.GetMessage());
+ }
+
+ [Fact]
+ public async Task UnsafeModifierInSignatureFileReported()
+ {
+ var source = """
+ namespace N;
+ public unsafe class C
+ {
+ public delegate* F;
+ }
+ """;
+ // 'unsafe' has no signature impact, so it is rejected even though it is required to write
+ // the equivalent declaration in real C#.
+ var sig = """
+ namespace N;
+ public unsafe class C
+ {
+ public delegate* F;
+ }
+ """;
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG004", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NonConstFieldInitializerReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public static int X;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public static int X = 5;
+ }
+ """;
+ var diagnostics = await RunAsync(source, sig);
+ // The initializer is invisible to the comparison (the field is not const), so it is
+ // rejected, but the fields themselves still match.
+ Assert.Equal(1, diagnostics.Count(d => d.Id == "CSSIG004"));
+ Assert.DoesNotContain(diagnostics, d => d.Id is "CSSIG001" or "CSSIG002");
+ }
+
+ [Fact]
+ public async Task RoundTripClassMembers()
+ {
+ await AssertRoundTripsAsync("""
+ namespace N
+ {
+ public abstract class C
+ {
+ public const int K = 5;
+ public static readonly string S;
+ protected C() { }
+ public C(int x) { }
+ public virtual int M(T value, in int by, ref string s, out bool b) { b = true; return 0; }
+ public string Name { get; set; }
+ public int ReadOnly { get; }
+ public int this[int i] => i;
+ public event System.Action E;
+ }
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task RoundTripStructInterfaceEnumDelegate()
+ {
+ await AssertRoundTripsAsync("""
+ namespace N
+ {
+ public interface IThing
+ {
+ int Compute(string s);
+ int Value { get; set; }
+ }
+
+ public struct Point
+ {
+ public int X;
+ public int Y;
+ public readonly int Sum() => X + Y;
+ }
+
+ public enum Color : byte
+ {
+ Red = 1,
+ Green = 2,
+ Blue = 4,
+ }
+
+ public delegate int Transform(T input, ref int state);
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task RoundTripNestedTypesAndStaticClass()
+ {
+ await AssertRoundTripsAsync("""
+ namespace N.Inner
+ {
+ public static class Helpers
+ {
+ public static int Add(int a, int b) => a + b;
+
+ public sealed class Nested
+ {
+ public int Value;
+ }
+ }
+ }
+ """);
+ }
+
+ [Fact]
+ public async Task RoundTripFunctionPointerAndVolatile()
+ {
+ await AssertRoundTripsAsync("""
+ namespace N
+ {
+ public unsafe class C
+ {
+ public static volatile int Flag;
+ public delegate* Callback;
+ }
+ }
+ """);
+ }
+
+ /// Generates a .cssig from and asserts that feeding it
+ /// back through the analyzer reports no diagnostics.
+ private static async Task AssertRoundTripsAsync(string source, bool nullable = false)
+ {
+ var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None);
+ var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true);
+ if (nullable)
+ {
+ compilationOptions = compilationOptions.WithNullableContextOptions(NullableContextOptions.Enable);
+ }
+
+ var compilation = CSharpCompilation.Create(
+ "TestProject",
+ new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") },
+ references,
+ compilationOptions);
+
+ var generated = CsSigWriter.Write(compilation);
+ var diagnostics = nullable
+ ? await RunNullableAsync(source, generated)
+ : await RunAsync(source, generated);
+ Assert.Empty(diagnostics);
+ }
+
+ [Fact]
+ public async Task ReadonlyStructMethodMismatchReported()
+ {
+ var source = """
+ namespace N;
+ public struct S
+ {
+ public int X;
+ public readonly int Get() => X;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public struct S
+ {
+ public int X;
+ public int Get();
+ }
+ """;
+ // The project method is `readonly`; the signature's is not. That difference affects the
+ // calling convention of `this`, so it must be reported under both equivalences.
+ var diagnostic = Assert.Single(await RunAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NullableReturnAnnotationMismatchReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public string? M() => null;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public string M();
+ }
+ """;
+
+ // The project returns `string?` but the signature declares `string`: a nullable-annotation
+ // difference. It is observable only in source, so it reports as a source-equivalence change.
+ var diagnostic = Assert.Single(await RunNullableAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NullableParameterAnnotationMismatchReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public void M(string? s) { }
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public void M(string s);
+ }
+ """;
+
+ var diagnostic = Assert.Single(await RunNullableAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NullableFieldAnnotationMismatchReported()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public string? F;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public string F;
+ }
+ """;
+
+ var diagnostic = Assert.Single(await RunNullableAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NullableNestedTypeArgumentAnnotationMismatchReported()
+ {
+ var source = """
+ using System.Collections.Generic;
+ namespace N;
+ public class C
+ {
+ public List M() => new();
+ }
+ """;
+ var sig = """
+ using System.Collections.Generic;
+ namespace N;
+ public class C
+ {
+ public List M();
+ }
+ """;
+
+ // The outer type matches; only the nullability of the type argument differs.
+ var diagnostic = Assert.Single(await RunNullableAsync(source, sig));
+ Assert.Equal("CSSIG005", diagnostic.Id);
+ }
+
+ [Fact]
+ public async Task NullableAnnotationIgnoredUnderBinaryEquivalence()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public string? M() => null;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public string M();
+ }
+ """;
+
+ // Nullable annotations have no binary impact, so a `string?`/`string` difference is invisible
+ // when only binary equivalence is enforced.
+ Assert.Empty(await RunNullableWithEquivalenceAsync(source, "binary", sig));
+ }
+
+ [Fact]
+ public async Task MatchingNullableAnnotationAccepted()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public string? M(string? s) => s;
+ public string N(string s) => s;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public string? M(string? s);
+ public string N(string s);
+ }
+ """;
+
+ Assert.Empty(await RunNullableAsync(source, sig));
+ }
+
+ [Fact]
+ public async Task NullableMembersRoundTrip()
+ {
+ await AssertRoundTripsAsync(
+ """
+ using System.Collections.Generic;
+ namespace N;
+ public class C
+ {
+ public string? Field;
+ public string? Property { get; set; }
+ public string? M(string? a, List b) => a;
+ }
+ """,
+ nullable: true);
+ }
+
+ [Fact]
+ public async Task CodeFixRegeneratesFileThatAlreadyDeclaresTheType()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ public int Extra() => 1;
+ }
+ """;
+ var sig = """
+ namespace N;
+ public class C
+ {
+ public int M();
+ }
+ """;
+
+ // 'MyApi.cssig' already declares N.C, so the only offered fix regenerates that file.
+ using var harness = await CodeFixHarness.CreateAsync(source, ("MyApi.cssig", sig));
+ var actions = await harness.RegisterFirstAsync();
+
+ var single = Assert.Single(actions);
+ Assert.Equal("Add missing API to 'MyApi.cssig'", single.Title);
+
+ var files = await CodeFixHarness.ApplyAsync(single, harness.ProjectId);
+ Assert.Contains("public int Extra();", files["MyApi.cssig"]);
+ Assert.Empty(await RunAsync(source, files["MyApi.cssig"]));
+ }
+
+ [Fact]
+ public async Task CodeFixDefaultPopulatesPublicApiFileForUndeclaredType()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M(string s) => s.Length;
+ public string Name { get; set; } = "";
+ }
+ """;
+
+ // Bootstrap workflow: an empty PublicAPI.cssig exists but declares nothing, so N.C is
+ // undeclared. Two fixes are offered; the default (first) targets PublicAPI.cssig.
+ using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", ""));
+ var actions = await harness.RegisterFirstAsync();
+
+ Assert.Equal(2, actions.Length);
+ Assert.Equal("Add API to 'PublicAPI.cssig'", actions[0].Title);
+ Assert.Equal("Add API to 'C.cssig'", actions[1].Title);
+
+ var files = await CodeFixHarness.ApplyAsync(actions[0], harness.ProjectId);
+ var generated = files["PublicAPI.cssig"];
+ Assert.Contains("public int M(string s);", generated);
+ Assert.Contains("public string Name { get; set; }", generated);
+ Assert.Empty(await RunAsync(source, generated));
+ }
+
+ [Fact]
+ public async Task CodeFixPerTypeOptionCreatesTypeNamedFile()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+
+ using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", ""));
+ var actions = await harness.RegisterFirstAsync();
+
+ // The second option writes into .cssig, leaving PublicAPI.cssig untouched.
+ var files = await CodeFixHarness.ApplyAsync(actions[1], harness.ProjectId);
+ Assert.Contains("public int M();", files["C.cssig"]);
+ Assert.Equal("", files["PublicAPI.cssig"]);
+ Assert.Empty(await RunAsync(source, files["C.cssig"]));
+ }
+
+ [Fact]
+ public async Task CodeFixRoutesToDeclaringFileNotPublicApi()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ """;
+ var cFile = """
+ namespace N;
+ public class C
+ {
+ }
+ """;
+
+ // N.C is declared in C.cssig, so even with PublicAPI.cssig present the fix targets C.cssig.
+ using var harness = await CodeFixHarness.CreateAsync(
+ source, ("PublicAPI.cssig", ""), ("C.cssig", cFile));
+ var actions = await harness.RegisterFirstAsync();
+
+ var single = Assert.Single(actions);
+ Assert.Equal("Add missing API to 'C.cssig'", single.Title);
+
+ var files = await CodeFixHarness.ApplyAsync(single, harness.ProjectId);
+ Assert.Contains("public int M();", files["C.cssig"]);
+ Assert.Equal("", files["PublicAPI.cssig"]);
+ }
+
+ [Fact]
+ public async Task CodeFixFixAllPopulatesPublicApiWithEveryType()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ public class D
+ {
+ public int N() => 0;
+ }
+ """;
+
+ using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", ""));
+ var files = await harness.ApplyFixAllAsync(CsSigCodeFixProvider.ToPublicApiKey);
+
+ var generated = files["PublicAPI.cssig"];
+ Assert.Contains("class C", generated);
+ Assert.Contains("class D", generated);
+ Assert.Empty(await RunAsync(source, generated));
+ }
+
+ [Fact]
+ public async Task CodeFixFixAllPerTypeCreatesOneFileEach()
+ {
+ var source = """
+ namespace N;
+ public class C
+ {
+ public int M() => 0;
+ }
+ public class D
+ {
+ public int N() => 0;
+ }
+ """;
+
+ using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", ""));
+ var files = await harness.ApplyFixAllAsync(CsSigCodeFixProvider.ToTypeFileKey);
+
+ Assert.Contains("public int M();", files["C.cssig"]);
+ Assert.Contains("public int N();", files["D.cssig"]);
+ Assert.Equal("", files["PublicAPI.cssig"]);
+ Assert.Empty(await RunAsync(source, files["C.cssig"], files["D.cssig"]));
+ }
+
+ /// An in-memory workspace harness for exercising :
+ /// builds a project with a source document plus named .cssig additional documents, runs
+ /// the analyzer, and exposes helpers to register and apply the resulting fixes.
+ private sealed class CodeFixHarness : System.IDisposable
+ {
+ private readonly Microsoft.CodeAnalysis.AdhocWorkspace _workspace;
+
+ public ProjectId ProjectId { get; }
+ public DocumentId SourceId { get; }
+
+ private CodeFixHarness(Microsoft.CodeAnalysis.AdhocWorkspace workspace, ProjectId projectId, DocumentId sourceId)
+ {
+ _workspace = workspace;
+ ProjectId = projectId;
+ SourceId = sourceId;
+ }
+
+ public void Dispose() => _workspace.Dispose();
+
+ public static async Task CreateAsync(string source, params (string name, string content)[] sigFiles)
+ {
+ var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None);
+
+ var projectId = ProjectId.CreateNewId();
+ var sourceId = DocumentId.CreateNewId(projectId);
+
+ var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace();
+ var solution = workspace.CurrentSolution
+ .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp)
+ .AddMetadataReferences(projectId, references)
+ .WithProjectCompilationOptions(
+ projectId,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true))
+ .AddDocument(sourceId, "Test.cs", source);
+
+ foreach (var (name, content) in sigFiles)
+ {
+ var sigId = DocumentId.CreateNewId(projectId);
+ solution = solution.AddAdditionalDocument(
+ sigId, name, SourceText.From(content), filePath: name);
+ }
+
+ Assert.True(workspace.TryApplyChanges(solution));
+ return new CodeFixHarness(workspace, projectId, sourceId);
+ }
+
+ private Project Project => _workspace.CurrentSolution.GetProject(ProjectId)!;
+
+ private async Task> MissingDiagnosticsAsync()
+ {
+ var project = Project;
+ var compilation = (await project.GetCompilationAsync(CancellationToken.None))!;
+ var withAnalyzers = compilation.WithAnalyzers(
+ ImmutableArray.Create(new CsSigAnalyzer()), project.AnalyzerOptions);
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None);
+ return diagnostics.Where(d => d.Id == "CSSIG002").ToImmutableArray();
+ }
+
+ /// Registers fixes for the first CSSIG002 diagnostic and returns the offered actions
+ /// in registration order.
+ public async Task> RegisterFirstAsync()
+ {
+ var diagnostics = await MissingDiagnosticsAsync();
+ var trigger = diagnostics.First();
+
+ var actions = ImmutableArray.CreateBuilder();
+ var context = new CodeFixContext(
+ Project.GetDocument(SourceId)!, trigger, (a, _) => actions.Add(a), CancellationToken.None);
+ await new CsSigCodeFixProvider().RegisterCodeFixesAsync(context);
+ return actions.ToImmutable();
+ }
+
+ /// Invokes the provider's FixAll with the given equivalence key across the whole
+ /// project.
+ public async Task> ApplyFixAllAsync(string equivalenceKey)
+ {
+ var diagnostics = await MissingDiagnosticsAsync();
+ var provider = new CsSigCodeFixProvider();
+ var fixAllContext = new FixAllContext(
+ Project.GetDocument(SourceId)!,
+ provider,
+ FixAllScope.Project,
+ equivalenceKey,
+ provider.FixableDiagnosticIds,
+ new CollectedDiagnosticProvider(diagnostics),
+ CancellationToken.None);
+
+ var action = await provider.GetFixAllProvider()!.GetFixAsync(fixAllContext);
+ return await ApplyAsync(action!, ProjectId);
+ }
+
+ /// Applies a code action and returns the resulting .cssig documents as a
+ /// name -> text map.
+ public static async Task> ApplyAsync(CodeAction action, ProjectId projectId)
+ {
+ var operations = await action.GetOperationsAsync(CancellationToken.None);
+ var changed = operations.OfType().Single().ChangedSolution;
+
+ var result = new Dictionary();
+ foreach (var doc in changed.GetProject(projectId)!.AdditionalDocuments)
+ {
+ result[doc.Name] = (await doc.GetTextAsync(CancellationToken.None)).ToString();
+ }
+
+ return result;
+ }
+
+ private sealed class CollectedDiagnosticProvider : FixAllContext.DiagnosticProvider
+ {
+ private readonly ImmutableArray _diagnostics;
+
+ public CollectedDiagnosticProvider(ImmutableArray diagnostics) => _diagnostics = diagnostics;
+
+ public override Task> GetAllDiagnosticsAsync(
+ Project project, CancellationToken cancellationToken)
+ => Task.FromResult>(_diagnostics);
+
+ public override Task> GetDocumentDiagnosticsAsync(
+ Document document, CancellationToken cancellationToken)
+ => Task.FromResult>(_diagnostics);
+
+ public override Task> GetProjectDiagnosticsAsync(
+ Project project, CancellationToken cancellationToken)
+ => Task.FromResult>(_diagnostics);
+ }
+ }
+
+ private static Task> RunAsync(string source, params string[] signatureFiles)
+ => RunCoreAsync(source, equivalence: null, nullable: false, signatureFiles);
+
+ private static Task> RunNullableAsync(string source, params string[] signatureFiles)
+ => RunCoreAsync(source, equivalence: null, nullable: true, signatureFiles);
+
+ private static Task> RunWithEquivalenceAsync(
+ string source, string? equivalence, params string[] signatureFiles)
+ => RunCoreAsync(source, equivalence, nullable: false, signatureFiles);
+
+ private static Task> RunNullableWithEquivalenceAsync(
+ string source, string? equivalence, params string[] signatureFiles)
+ => RunCoreAsync(source, equivalence, nullable: true, signatureFiles);
+
+ private static async Task> RunCoreAsync(
+ string source, string? equivalence, bool nullable, params string[] signatureFiles)
+ {
+ var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None);
+
+ var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true);
+ if (nullable)
+ {
+ compilationOptions = compilationOptions.WithNullableContextOptions(NullableContextOptions.Enable);
+ }
+
+ var compilation = CSharpCompilation.Create(
+ "TestProject",
+ new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") },
+ references,
+ compilationOptions);
+
+ var additionalFiles = ImmutableArray.CreateRange(
+ signatureFiles.Select((content, i) => (AdditionalText)new InMemoryAdditionalText($"Api{i}.cssig", content)));
+
+ var options = equivalence is null
+ ? new AnalyzerOptions(additionalFiles)
+ : new AnalyzerOptions(additionalFiles, new TestConfigOptionsProvider(
+ ImmutableDictionary.Empty.Add("build_property.CsSigEquivalence", equivalence)));
+
+ var withAnalyzers = compilation.WithAnalyzers(
+ ImmutableArray.Create(new CsSigAnalyzer()),
+ options);
+
+ var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None);
+ return diagnostics.OrderBy(d => d.Id).ToImmutableArray();
+ }
+
+ private sealed class TestConfigOptionsProvider : AnalyzerConfigOptionsProvider
+ {
+ private readonly AnalyzerConfigOptions _global;
+
+ public TestConfigOptionsProvider(ImmutableDictionary globals)
+ => _global = new TestConfigOptions(globals);
+
+ public override AnalyzerConfigOptions GlobalOptions => _global;
+
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestConfigOptions.Empty;
+
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => TestConfigOptions.Empty;
+ }
+
+ private sealed class TestConfigOptions : AnalyzerConfigOptions
+ {
+ public static readonly TestConfigOptions Empty = new(ImmutableDictionary.Empty);
+
+ private readonly ImmutableDictionary _values;
+
+ public TestConfigOptions(ImmutableDictionary values) => _values = values;
+
+ public override bool TryGetValue(
+ string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value)
+ {
+ if (_values.TryGetValue(key, out var v))
+ {
+ value = v;
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
+ }
+
+ private sealed class InMemoryAdditionalText : AdditionalText
+ {
+ private readonly SourceText _text;
+
+ public InMemoryAdditionalText(string path, string text)
+ {
+ Path = path;
+ _text = SourceText.From(text);
+ }
+
+ public override string Path { get; }
+
+ public override SourceText GetText(CancellationToken cancellationToken = default) => _text;
+ }
+}
diff --git a/test/test/IndentingBuilderTests.cs b/test/test/IndentingBuilderTests.cs
index c0d9da6..059486e 100644
--- a/test/test/IndentingBuilderTests.cs
+++ b/test/test/IndentingBuilderTests.cs
@@ -1,9 +1,15 @@
+extern alias IbLib;
using System;
using System.Text;
using Xunit;
namespace StaticCs.Tests;
+// IndentingBuilder is also compiled into StaticCs.CsSig.Analyzers (it consumes the source-only
+// StaticCS.IndentingBuilder package), so the simple name is ambiguous in this project. Bind it to
+// the standalone IndentingBuilder library under test via the IbLib alias.
+using IndentingBuilder = IbLib::StaticCs.IndentingBuilder;
+
public sealed class IndentingBuilderTests
{
[Fact]
diff --git a/test/test/test.csproj b/test/test/test.csproj
index eff403d..4b9fe94 100644
--- a/test/test/test.csproj
+++ b/test/test/test.csproj
@@ -1,35 +1,33 @@
-
-
- net8.0
- enable
- false
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net8.0
+ enable
+ false
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+