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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/wiki/Analyzers.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ These are MSBuild diagnostics rather than compiler analyzers, so they are not tr
| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or did not declare) a source-generator analyzer file. |
| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.<Name>`. |
| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither part of the closure nor a compiler-host assembly. |
| `PSGFR41` | `Purview.SourceGeneratorFramework` merge tool | A component's public surface exposes framework types that the merge internalizes. Raised by the compiler analyzer of the same id at design time and by the merge pass as a `message` (default), `warning` or `error`. |
| `PSGFR42` | `Purview.SourceGeneratorFramework` merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. |

Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or
`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`).
Expand Down Expand Up @@ -66,6 +68,7 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00
| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. |
| `PSGFR39` | A non-packable Roslyn component that explicitly opts out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework, otherwise the package embeds the loose framework DLL under `analyzers/`. |
| `PSGFR40` | In Roslyn components (`IsRoslynComponent=true`), reference SGF types as inline code (`<c>Type</c>`) instead of a `cref`: copied documentation must not depend on cref resolution. |
| `PSGFR41` | In Roslyn components whose framework implementation is merged, a public member (or generic constraint) whose signature references an SGF type: the merge internalizes every SGF type, so the signature is left referring to an internal type. Make the member or its declaring type non-public. |

## Type-library and attribute-model diagnostics

Expand Down Expand Up @@ -96,6 +99,10 @@ analyzer rules above, including:
to the structured `IfBlock`/`ElseIf`/`Else` APIs (`PSGFR23`).
- `CodeWriterToStringCodeFixProvider` — replaces embedded `CodeWriter` string interpolation (`PSGFR29`).
- `PreferInlineCodeForFrameworkCrefCodeFixProvider` — rewrites SGF XML doc `cref` targets to inline code (`<c>Type</c>`) (`PSGFR40`).
- `MakeComponentSurfaceNonPublicCodeFixProvider` — makes the exposing member, or its declaring type,
non-public (`PSGFR41`).
- `MakeTypeLibrarySpecNonPublicCodeFixProvider` — declares a type-library spec non-public in a merged
component (`TLB0021`).
- `AttributeDataModelSymbolPropertyCodeFixProvider` — fixes attribute-data-model symbol properties.
- `ReorganizeExtensionClassCodeFixProvider` — renames (`PSGFR35`), splits multi-receiver classes
(`PSGFR37`), moves the class under `Extensions/{ReceiverNamespace}/`, and updates referencing files
Expand Down
48 changes: 40 additions & 8 deletions docs/wiki/Packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,21 +215,53 @@ The merge tool therefore runs a deterministic internalization pass over the merg
(`FrameworkTypeInternalizer`) after `ILRepack` finishes:

- every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children,
including the generated `Generators` attribute set) becomes non-public;
including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated
through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type:
the framework's nested containers (the generated type-library namespace classes, the nested
operator/enum groups, the `CodeWriter` scopes) are only reachable through their declaring type;
- the types the framework's own generators emit into the component — recognized by the tool name on
their `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`,
`AttributeDataModelGenerator`) — become non-public as well, so a generated type library can never
leak a `TypeIdentity`/`TypeReference` member as public API in the shipped analyzer;
- `Microsoft.CodeAnalysis.EmbeddedAttribute` (the framework-emitted marker) becomes non-public;
- **Roslyn component entry points are never internalized** — a generator, analyzer, code fix
provider, or refactoring provider that is reachable from the framework namespace stays public,
because Roslyn only instantiates public components (PSGFR27). This is what keeps the framework's
own bundled analyzers working after the pass;
- the pass reports the merge result: leftover public framework types fail the merge (exit code `5`),
and public component members whose signature exposes a framework type are logged as warnings so the
component author can make them (or their declaring type) non-public.

The component's own generated types (the type library, attribute data models) keep their accessibility
in the component assembly: TLB0015 requires a hand-written partial to be declared
and public component members whose signature exposes a framework type are logged so the component
author can make them (or their declaring type) non-public. The report follows visibility through the
declaring chain too: a public nested type inside an internal type — for example the
compiler-synthesised `<G>$`/`<M>$` extension containers emitted for an extension class — is not
reachable from outside the component, so it is not part of the public surface and is not reported.

Ownership is seeded with the framework assembly's own type identities, so a framework type that lives
outside the framework namespace (the `Microsoft.CodeAnalysis.*Extensions` and `System.StringExtensions`
extension classes, for example) is internalized as well, and the artifact's assembly-level
`InternalsVisibleTo` / `IgnoresAccessChecksTo` grants are removed because a shipped analyzer is not the
component's own assembly.

The pass is configurable from the component's project:

| Property | Default | Effect |
|----------|---------|--------|
| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. |
| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. |
| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none` (dropped). |
| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. |
| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; set it to a source or project path for IDE navigation. |

All five participate in the merge's content tag, so changing one re-runs the merge (and re-emits its
findings) instead of reusing a cached artifact. The compiler analyzer of the same id (`PSGFR41`) reports
the same condition while you edit, so the surface is fixed before the merge runs.

The component's own generated types (the type library, attribute data models) keep their generated
accessibility in the component's **bin output**: TLB0015 requires a hand-written partial to be declared
`public static partial` so it can merge with the generated library, and in-repo consumers such as code
fixers and sibling assemblies compile against it. Self-containment is therefore enforced at the merge
boundary rather than by rewriting generated accessibility. See [Type-Library.md](Type-Library.md).
fixers and sibling assemblies compile against it. Only the merged analyzer artifact internalizes them
(see above), so self-containment is enforced at the merge boundary without rewriting the generated
accessibility an in-repo consumer compiles against. A merged artifact is never referenced as a
compile-time dependency, which is what makes that split safe. See [Type-Library.md](Type-Library.md).

> A merged component is an analyzer artifact and must never be referenced as a compile-time
> dependency. Tests that need to run a *packaged* generator load it out of band — see
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Type-Library.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ Set the MSBuild property `DisablePurviewTypeLibraryGenerator` to `true` to disab

## Validation

`TypeLibraryValidationAnalyzer` reports `TLB0001`–`TLB0020` for invalid specs, enum value members, and
`TypeLibraryValidationAnalyzer` reports `TLB0001`–`TLB0021` for invalid specs, enum value members, and
type library partial extensions (non-static class, member type that is not `TypeIdentity`/`TypeReference`,
unresolvable type/namespace, duplicate members, invalid class name, invalid namespace, invalid member
accessibility, value members without an initializer, marker members without an explicit `= default`, a spec
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-sourcegenerator-framework",
"version": "1.0.0-prerelease.53",
"version": "1.0.0-prerelease.54",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ PSGFR37 | Purview.SourceGeneratorFramework | Warning | Extension class extends m
PSGFR38 | Purview.SourceGeneratorFramework | Warning | Extension class is missing EditorBrowsable
PSGFR39 | Purview.SourceGeneratorFramework | Error | Roslyn component must produce a self-contained analyzer
PSGFR40 | Purview.SourceGeneratorFramework | Warning | SGF cref should be inline code
PSGFR41 | Purview.SourceGeneratorFramework | Warning | Component public surface exposes framework types
TLB0014 | TypeLibrary | Warning | Type library partial extension is declared in a different namespace
TLB0015 | TypeLibrary | Info | Type library partial extension must be declared 'public static partial'
TLB0016 | TypeLibrary | Error | Enum value member type must be TypeIdentity or EnumValueDefinition
TLB0017 | TypeLibrary | Error | Enum value member references an enum type that is not declared
TLB0018 | TypeLibrary | Error | Duplicate enum value member
TLB0019 | TypeLibrary | Info | Duplicate enum value
TLB0020 | TypeLibrary | Error | Enum values member references a type that is not an enum
TLB0021 | TypeLibrary | Info | Type-library spec should not be public in a merged component
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Purview.SourceGeneratorFramework.Analyzers;

/// <summary>
/// Flags public members of a merged Roslyn component whose signature references a
/// <c>Purview.SourceGeneratorFramework</c> type. The merge internalizes every framework type in the
/// shipped analyzer, so such a member becomes a public signature over an internal type: unusable, and
/// the merge reports it (as <c>PSGFR41</c>) after a full build. This analyzer reports the same finding
/// while the author is editing, so the surface is fixed before the merge ever runs.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ComponentPublicSurfaceAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "PSGFR41";

internal const string FrameworkAssemblyName = "Purview.SourceGeneratorFramework";

public static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
"Component public surface exposes framework types",
"'{0}' is public and exposes the Purview.SourceGeneratorFramework type '{1}', which the merge internalizes in the shipped analyzer; make the member or its declaring type non-public",
"Purview.SourceGeneratorFramework",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "A merged Roslyn component internalizes every Purview.SourceGeneratorFramework type it ships, so a public member whose signature references one leaves a public signature over an internal type in the analyzer. The merge reports the same finding as PSGFR41."
);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

public override void Initialize(AnalysisContext context)
{
if (context is null)
throw new ArgumentNullException(nameof(context));

context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(startContext =>
{
// Only a component whose framework implementation is merged loses the framework types, so
// only its public surface can be left unusable.
if (!FrameworkMergeFacts.WillBeMerged(startContext.Options))
return;

if (GetFrameworkAssembly(startContext.Compilation) is not { } frameworkAssembly)
return;

startContext.RegisterSymbolAction(
context => AnalyzeNamedType(context, frameworkAssembly),
SymbolKind.NamedType
);
});
}

static void AnalyzeNamedType(SymbolAnalysisContext context, IAssemblySymbol frameworkAssembly)
{
if (context.Symbol is not INamedTypeSymbol type)
return;

// The framework's own types are what the merge internalizes; the component's are the surface.
if (SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, frameworkAssembly))
return;

// Generated code is not hand-editable: the merge internalizes generator-emitted type libraries
// and attribute sets itself.
if (IsGenerated(type))
return;

if (!RoslynComponentDiscovery.IsEffectivelyPublic(type))
return;

foreach (var (symbol, frameworkType) in FindExposingMembers(type, frameworkAssembly))
{
if (symbol.Locations.FirstOrDefault(static location => location.IsInSource) is not { } location)
continue;

context.ReportDiagnostic(
Diagnostic.Create(
Rule,
location,
symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
frameworkType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)
)
);
}
}

static IAssemblySymbol? GetFrameworkAssembly(Compilation compilation)
{
foreach (var reference in compilation.References)
{
if (
compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly
&& string.Equals(assembly.Identity.Name, FrameworkAssemblyName, StringComparison.OrdinalIgnoreCase)
)
{
return assembly;
}
}

return null;
}

/// <summary>
/// Enumerates the parts of the type's public surface that reference a framework type: the base type,
/// the implemented interfaces, the generic constraints, and the public or protected members.
/// </summary>
static IEnumerable<(ISymbol Symbol, ITypeSymbol FrameworkType)> FindExposingMembers(
INamedTypeSymbol type,
IAssemblySymbol frameworkAssembly
)
{
if (FindFrameworkType(type.BaseType, frameworkAssembly) is { } fromBase)
yield return (type, fromBase);

foreach (var @interface in type.Interfaces)
{
if (FindFrameworkType(@interface, frameworkAssembly) is { } fromInterface)
yield return (type, fromInterface);
}

foreach (var typeParameter in type.TypeParameters)
{
if (FindFrameworkType(typeParameter, frameworkAssembly) is { } fromConstraint)
yield return (typeParameter, fromConstraint);
}

foreach (var member in type.GetMembers())
{
if (!IsPublicSurfaceMember(member) || IsGenerated(member))
continue;

if (FindExposedType(member, frameworkAssembly) is { } fromMember)
yield return (member, fromMember);
}
}

static ITypeSymbol? FindExposedType(ISymbol member, IAssemblySymbol frameworkAssembly) =>
member switch
{
IFieldSymbol field => FindFrameworkType(field.Type, frameworkAssembly),
IPropertySymbol property => FindFrameworkType(property.Type, frameworkAssembly)
?? FirstFrameworkType(property.Parameters, frameworkAssembly),
IEventSymbol @event => FindFrameworkType(@event.Type, frameworkAssembly),
IMethodSymbol method => FindFrameworkType(method.ReturnType, frameworkAssembly)
?? FirstFrameworkType(method.Parameters, frameworkAssembly)
?? method
.TypeParameters.Select(typeParameter => FindFrameworkType(typeParameter, frameworkAssembly))
.FirstOrDefault(static type => type is not null),
_ => null,
};

static ITypeSymbol? FirstFrameworkType(
ImmutableArray<IParameterSymbol> parameters,
IAssemblySymbol frameworkAssembly
) =>
parameters
.Select(parameter => FindFrameworkType(parameter.Type, frameworkAssembly))
.FirstOrDefault(static type => type is not null);

/// <summary>
/// Returns the framework type a signature references, if any: the type itself, one of its generic
/// arguments, an array element type, or a generic constraint.
/// </summary>
static ITypeSymbol? FindFrameworkType(ITypeSymbol? type, IAssemblySymbol frameworkAssembly)
{
switch (type)
{
case null:
return null;
case IArrayTypeSymbol array:
return FindFrameworkType(array.ElementType, frameworkAssembly);
case IPointerTypeSymbol pointer:
return FindFrameworkType(pointer.PointedAtType, frameworkAssembly);
case ITypeParameterSymbol typeParameter:
foreach (var constraint in typeParameter.ConstraintTypes)
{
if (FindFrameworkType(constraint, frameworkAssembly) is { } fromConstraint)
return fromConstraint;
}

return null;
case INamedTypeSymbol named:
if (IsFrameworkType(named, frameworkAssembly))
return named;

foreach (var argument in named.TypeArguments)
{
if (FindFrameworkType(argument, frameworkAssembly) is { } fromArgument)
return fromArgument;
}

return null;
default:
return IsFrameworkType(type, frameworkAssembly) ? type : null;
}
}

static bool IsFrameworkType(ITypeSymbol type, IAssemblySymbol frameworkAssembly) =>
SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, frameworkAssembly);

/// <summary>
/// Detects a type the framework's generators emitted into the component (the generated type library,
/// attribute data models, marker attribute sets). Those members are not hand-editable, and the merge
/// internalizes them itself.
/// </summary>
static bool IsGenerated(ISymbol symbol)
{
for (var current = symbol; current is not null; current = current.ContainingType)
{
foreach (var attribute in current.GetAttributes())
{
var attributeName = attribute.AttributeClass?.ToDisplayString();

if (
string.Equals(
attributeName,
"System.CodeDom.Compiler.GeneratedCodeAttribute",
StringComparison.Ordinal
)
|| string.Equals(
attributeName,
"System.Runtime.CompilerServices.CompilerGeneratedAttribute",
StringComparison.Ordinal
)
)
{
return true;
}
}
}

return false;
}

static bool IsPublicSurfaceMember(ISymbol member)
{
if (member.IsImplicitlyDeclared)
return false;

if (
member.DeclaredAccessibility
is not (Accessibility.Public or Accessibility.Protected or Accessibility.ProtectedOrInternal)
)
{
return false;
}

// Property and event accessors are reported through their property or event.
return member switch
{
IFieldSymbol or IPropertySymbol or IEventSymbol => true,
IMethodSymbol method => method.MethodKind
is MethodKind.Ordinary
or MethodKind.Constructor
or MethodKind.UserDefinedOperator
or MethodKind.Conversion,
_ => false,
};
}
}
Loading
Loading