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
34 changes: 0 additions & 34 deletions .agents/agents/sdk-consumer-setup.md

This file was deleted.

26 changes: 0 additions & 26 deletions .agents/prompts/sdk-diagnose-agent-folder-copy.md

This file was deleted.

8 changes: 0 additions & 8 deletions .agents/skills/project-placement-defaults/.gitignore

This file was deleted.

8 changes: 0 additions & 8 deletions .agents/skills/sdk-configuration-reference/.gitignore

This file was deleted.

8 changes: 0 additions & 8 deletions .agents/skills/sdk-project-behavior-and-detection/.gitignore

This file was deleted.

2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"allowPrerelease": false
},
"msbuild-sdks": {
"Purview.BuildSdk": "1.0.0-prerelease.60"
"Purview.BuildSdk": "1.0.3"
},
"test": {
"runner": "Microsoft.Testing.Platform"
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.54",
"version": "1.0.0",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,46 @@ public static ImmutableArray<string> CollectTypeFullNames(string assemblyPath)
return [.. assembly.MainModule.Types.SelectMany(MergeToolRunner.Flatten).Select(static type => type.FullName)];
}

/// <summary>
/// Reads the assembly names an assembly grants internals access to. The merge uses the framework
/// assembly's grants to decide which grants in the merged component were copied in by the merge
/// (and must go) and which the component itself authored (and must stay) - see
/// <see cref="StripInternalsGrants"/>.
/// </summary>
/// <param name="assemblyPath">The assembly to read grants from.</param>
public static ImmutableArray<string> CollectInternalsGrants(string assemblyPath)
{
using var assembly = AssemblyDefinition.ReadAssembly(assemblyPath);

return
[
.. assembly
.CustomAttributes.Where(static attribute =>
s_internalsGrantAttributeFullNames.Contains(
attribute.AttributeType.FullName,
StringComparer.Ordinal
)
)
.Select(static attribute => GrantedAssemblyName(attribute))
.Where(static name => name.Length > 0),
];
}

/// <summary>
/// Reads the granted assembly's simple name from an internals-grant attribute, discarding any
/// <c>, PublicKey=...</c> suffix.
/// </summary>
static string GrantedAssemblyName(CustomAttribute attribute)
{
if (attribute.ConstructorArguments.Count == 0)
return string.Empty;

var value = attribute.ConstructorArguments[0].Value as string ?? string.Empty;
var comma = value.IndexOf(',', StringComparison.Ordinal);

return (comma < 0 ? value : value[..comma]).Trim();
}

/// <summary>
/// Internalizes every framework-owned type in the merged component, strips the assembly-level
/// internals grants the merge copied in, and returns a report of the changes plus anything that
Expand All @@ -130,12 +170,19 @@ public static ImmutableArray<string> CollectTypeFullNames(string assemblyPath)
/// <param name="warn">Optional sink for non-blocking findings, such as component members that expose framework types.</param>
/// <param name="ownedNamespaces">Namespace prefixes to internalize; defaults to the framework's own namespaces.</param>
/// <param name="ownedTypeFullNames">Additional type full names to internalize (normally the framework assembly's own type names).</param>
/// <param name="frameworkInternalsGrants">
/// The internals grants declared by the framework assembly. Only these are stripped from the merged
/// component, so a grant the component itself authored - the one that lets a companion code-fix
/// component read the generator's internal diagnostic identity - survives the merge. When
/// <see langword="null"/>, every grant is stripped.
/// </param>
public static FrameworkInternalizationReport Apply(
string assemblyPath,
IEnumerable<string> searchDirectories,
Action<string>? warn = null,
ImmutableArray<string>? ownedNamespaces = null,
ImmutableArray<string>? ownedTypeFullNames = null
ImmutableArray<string>? ownedTypeFullNames = null,
ImmutableArray<string>? frameworkInternalsGrants = null
)
{
var namespaces = ownedNamespaces ?? DefaultOwnedNamespaces;
Expand Down Expand Up @@ -190,7 +237,7 @@ public static FrameworkInternalizationReport Apply(
CollectExposingMembers(type, namespaces, typeFullNames, exposingMembers);
}

var strippedGrantCount = StripInternalsGrants(assembly);
var strippedGrantCount = StripInternalsGrants(assembly, frameworkInternalsGrants);

if (internalizedTypeCount > 0 || strippedGrantCount > 0)
assembly.Write(assemblyPath, new WriterParameters { WriteSymbols = hasSymbols });
Expand All @@ -217,21 +264,36 @@ [.. exposingMembers.Select(static entry => $"{entry.Owner}.{entry.Member}")],
}

/// <summary>
/// Removes the assembly-level internals grants the merge copied into the artifact. The merged
/// component is a shipped analyzer, not the component's own assembly: a leftover grant would let
/// an unrelated assembly (such as the framework's own test assemblies) reach the internalized
/// framework types, while the component's bin output keeps its grants for in-repo tests.
/// Removes the assembly-level internals grants the merge copied into the artifact from the
/// framework assembly. The merged component is a shipped analyzer, not the framework's own
/// assembly: a leftover framework grant would let an unrelated assembly (such as the framework's
/// own test assemblies) reach the internalized framework types.
/// <para>
/// Grants the component itself authored are kept. Stripping those broke the supported
/// component-to-component arrangement: a code-fix component that reads its generator's internal
/// diagnostic identity compiles and passes in-repo tests against the generator's unmerged bin
/// output, which keeps the grant, and then fails at runtime in the compiler host with a
/// <c>FieldAccessException</c> because the merged analyzer it actually loads had the grant
/// removed. Keeping the component's own grants makes the merged artifact behave like the assembly
/// the author compiled against.
/// </para>
/// </summary>
static int StripInternalsGrants(AssemblyDefinition assembly)
/// <param name="assembly">The merged component.</param>
/// <param name="frameworkInternalsGrants">
/// Grants declared by the framework assembly, or <see langword="null"/> to strip every grant.
/// </param>
static int StripInternalsGrants(AssemblyDefinition assembly, ImmutableArray<string>? frameworkInternalsGrants)
{
var removed = 0;
for (var index = assembly.CustomAttributes.Count - 1; index >= 0; index--)
{
var attribute = assembly.CustomAttributes[index];
if (!s_internalsGrantAttributeFullNames.Contains(attribute.AttributeType.FullName, StringComparer.Ordinal))
continue;

if (
!s_internalsGrantAttributeFullNames.Contains(
assembly.CustomAttributes[index].AttributeType.FullName,
StringComparer.Ordinal
)
frameworkInternalsGrants is { } frameworkGrants
&& !frameworkGrants.Contains(GrantedAssemblyName(attribute), StringComparer.Ordinal)
)
{
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ logger is not null
ownedTypeFullNames: Extend(
FrameworkTypeInternalizer.DefaultOwnedTypeFullNames,
[.. FrameworkTypeInternalizer.CollectTypeFullNames(frameworkPath), .. options.OwnedTypeFullNames]
)
),
// Only the framework's own grants are stripped. A grant the component authored - the one
// that lets a companion code-fix component read the generator's internal diagnostic
// identity - has to survive, or the merged analyzer the compiler host loads behaves
// differently from the assembly the author compiled and tested against.
frameworkInternalsGrants: FrameworkTypeInternalizer.CollectInternalsGrants(frameworkPath)
);

if (internalization.PublicFrameworkTypesRemaining.Length > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,24 @@ public sealed class PublicType
}
""";

/// <summary>
/// A component carrying both a grant the merge copied in from the framework and the component's own
/// grant to its companion code-fix assembly.
/// </summary>
const string ComponentWithMixedInternalsGrantsSource = """
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Purview.SourceGeneratorFramework.UnitTests")]
[assembly: InternalsVisibleTo("Fixture.Component.CodeFixers")]

namespace Fixture.Component
{
public sealed class PublicType
{
}
}
""";

[Test]
public async Task Apply_GivenOwnedPublicTypes_InternalizesThemAndKeepsComponentsPublic(
CancellationToken cancellationToken
Expand Down Expand Up @@ -551,6 +569,43 @@ await Assert
.IsFalse();
}

// The merge copies the framework assembly's own grants into the artifact, and those must go: a
// shipped analyzer is not the framework's assembly. A grant the *component* authored is different -
// it is what lets a companion code-fix component read the generator's internal diagnostic identity.
// Stripping it made the merged analyzer behave differently from the unmerged bin output the author
// compiled and tested against, so the code fix threw FieldAccessException only in the compiler host.
[Test]
public async Task Apply_GivenFrameworkGrants_StripsOnlyThoseAndKeepsTheComponentsOwn(
CancellationToken cancellationToken
)
{
// Arrange
cancellationToken.ThrowIfCancellationRequested();
using TestWorkspace workspace = new();
var componentPath = workspace.Compile("Fixture.Component", ComponentWithMixedInternalsGrantsSource);

// Act
var report = FrameworkTypeInternalizer.Apply(
componentPath,
[workspace.GetPath("Fixture.Component")],
ownedNamespaces: ["Purview.SourceGeneratorFramework"],
frameworkInternalsGrants: ["Purview.SourceGeneratorFramework.UnitTests"]
);

// Assert
await Assert.That(report.StrippedInternalsGrantCount).IsEqualTo(1);

using var component = AssemblyDefinition.ReadAssembly(componentPath);
var grants = component
.CustomAttributes.Where(static attribute =>
attribute.AttributeType.FullName == "System.Runtime.CompilerServices.InternalsVisibleToAttribute"
)
.Select(static attribute => (string)attribute.ConstructorArguments[0].Value)
.ToArray();

await Assert.That(grants).IsEquivalentTo(["Fixture.Component.CodeFixers"]);
}

[Test]
public async Task Apply_GivenGenericConstraintsExposingFrameworkTypes_ReportsThem(
CancellationToken cancellationToken
Expand Down
Loading