Skip to content

Commit 71a7964

Browse files
authored
Merge pull request #2825 from DavidObando/fix/issue-2816
Filter SDK-generated friend assembly attributes
2 parents a6c819c + a7e4301 commit 71a7964

6 files changed

Lines changed: 226 additions & 25 deletions

File tree

tools/cs2gs/Cs2Gs.Pipeline/GsharpTestProjectRunner.cs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -451,16 +451,27 @@ private static byte[] ComputeHash(string path)
451451
return sha256.ComputeHash(stream);
452452
}
453453

454-
private static string LibraryProject(GsharpTestProject project, string sdkVersion) =>
455-
$"<Project Sdk=\"{SdkPackageId}/{sdkVersion}\">\n" +
456-
"\n" +
457-
" <PropertyGroup>\n" +
458-
" <OutputType>Library</OutputType>\n" +
459-
" <TargetFramework>net10.0</TargetFramework>\n" +
460-
$" <RootNamespace>{project.LibraryRootNamespace}</RootNamespace>\n" +
461-
" </PropertyGroup>\n" +
462-
"\n" +
463-
"</Project>\n";
454+
private static string LibraryProject(GsharpTestProject project, string sdkVersion)
455+
{
456+
string friendAssemblies = project.LibraryFriendAssemblies.Count == 0
457+
? string.Empty
458+
: "\n <ItemGroup>\n" +
459+
string.Concat(project.LibraryFriendAssemblies.Select(name =>
460+
" <InternalsVisibleTo Include=\"" +
461+
System.Security.SecurityElement.Escape(name) +
462+
"\" />\n")) +
463+
" </ItemGroup>\n";
464+
return $"<Project Sdk=\"{SdkPackageId}/{sdkVersion}\">\n" +
465+
"\n" +
466+
" <PropertyGroup>\n" +
467+
" <OutputType>Library</OutputType>\n" +
468+
" <TargetFramework>net10.0</TargetFramework>\n" +
469+
$" <RootNamespace>{project.LibraryRootNamespace}</RootNamespace>\n" +
470+
" </PropertyGroup>\n" +
471+
friendAssemblies +
472+
"\n" +
473+
"</Project>\n";
474+
}
464475

465476
private static string TestsProject(GsharpTestProject project, string sdkVersion) =>
466477
$"<Project Sdk=\"{SdkPackageId}/{sdkVersion}\">\n" +
@@ -534,6 +545,9 @@ public sealed class GsharpTestProject
534545
/// <summary>Gets or sets the translated G# library source files.</summary>
535546
public IReadOnlyList<GsharpSourceFile> LibraryFiles { get; set; } = Array.Empty<GsharpSourceFile>();
536547

548+
/// <summary>Gets or sets friend assemblies declared by the source library project.</summary>
549+
public IReadOnlyList<string> LibraryFriendAssemblies { get; set; } = Array.Empty<string>();
550+
537551
/// <summary>Gets or sets the test project name (folder + assembly).</summary>
538552
public string TestsName { get; set; } = "Library.Tests";
539553

tools/cs2gs/Cs2Gs.Pipeline/IMigrationStage.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ public StageExecutionContext(
190190
/// <summary>Gets the source project's declared ProjectReference items.</summary>
191191
public List<DeclaredProjectItem> ProjectReferences { get; } = new List<DeclaredProjectItem>();
192192

193+
/// <summary>
194+
/// Gets friend assemblies contributed by generated C# sources, including
195+
/// SDK-generated <c>InternalsVisibleTo</c> items.
196+
/// </summary>
197+
public ISet<string> GeneratedFriendAssemblies { get; } =
198+
new HashSet<string>(StringComparer.Ordinal);
199+
193200
/// <summary>
194201
/// Gets or sets the absolute path of the assembly emitted by the Compile
195202
/// stage, published for the IL-verify stage to read (ADR-0115 §C). It is

tools/cs2gs/Cs2Gs.Pipeline/TestParityStage.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ private async Task<StageOutcome> RunLibraryParityAsync(
251251
LibraryFiles = context.EmittedFiles
252252
.Select(f => new GsharpSourceFile(Path.GetFileName(f.GsPath), f.GSharpSource))
253253
.ToList(),
254+
LibraryFriendAssemblies =
255+
context.Options.OutputLayout == MigrationOutputLayout.Repository
256+
? context.GeneratedFriendAssemblies.OrderBy(name => name, StringComparer.Ordinal).ToList()
257+
: Array.Empty<string>(),
254258
TestsName = libraryName + ".Tests",
255259
TestsRootNamespace = libraryName.Replace('-', '_') + ".Tests",
256260
TestFiles = tests.Files,

tools/cs2gs/Cs2Gs.Pipeline/TranslateStage.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ await CSharpProjectLoader.LoadProjectAsync(
273273
markMergedTypePartial: hasAnalyzerReferences,
274274
retainedFilePaths: retainedFilePaths);
275275

276-
EmitFriendAssemblyAnnotations(
276+
PreserveGeneratedFriendAssemblyAnnotations(
277277
context,
278278
currentProject,
279279
isReferencedProject,
@@ -461,21 +461,38 @@ await CSharpProjectLoader.LoadProjectAsync(
461461
return artifacts.Count == 0 ? StageOutcome.Passed() : StageOutcome.Failed(artifacts);
462462
}
463463

464-
private static void EmitFriendAssemblyAnnotations(
464+
private static void PreserveGeneratedFriendAssemblyAnnotations(
465465
StageExecutionContext context,
466466
LoadedCSharpProject project,
467467
bool isReferencedProject,
468468
ISet<string> usedOutputPaths)
469469
{
470470
const string attributeName = "System.Runtime.CompilerServices.InternalsVisibleToAttribute";
471+
var handAuthoredTrees = project.Documents
472+
.Select(document => document.SyntaxTree)
473+
.ToHashSet();
471474
List<string> friendAssemblies = project.Compilation.Assembly.GetAttributes()
472475
.Where(attribute =>
473476
attribute.AttributeClass?.ToDisplayString() == attributeName &&
474477
attribute.ConstructorArguments.Length == 1 &&
475-
attribute.ConstructorArguments[0].Value is string)
478+
attribute.ConstructorArguments[0].Value is string &&
479+
!handAuthoredTrees.Contains(attribute.ApplicationSyntaxReference?.SyntaxTree))
476480
.Select(attribute => (string)attribute.ConstructorArguments[0].Value)
477481
.Distinct(StringComparer.Ordinal)
478482
.ToList();
483+
if (!isReferencedProject)
484+
{
485+
foreach (string friendAssembly in friendAssemblies)
486+
{
487+
context.GeneratedFriendAssemblies.Add(friendAssembly);
488+
}
489+
}
490+
491+
if (context.Options.OutputLayout == MigrationOutputLayout.Repository)
492+
{
493+
return;
494+
}
495+
479496
if (friendAssemblies.Count == 0)
480497
{
481498
return;

tools/cs2gs/Cs2Gs.Tests/MigrationPipelineTests.cs

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.IO;
77
using System.Linq;
88
using System.Reflection;
9+
using System.Runtime.CompilerServices;
910
using System.Runtime.InteropServices;
1011
using System.Text.Json;
1112
using System.Threading.Tasks;
@@ -201,11 +202,12 @@ public async Task L2_StagesOneAndTwo_AreGreen_WithZeroArtifacts()
201202
}
202203

203204
/// <summary>
204-
/// MSBuild-generated friend-assembly metadata must become the equivalent
205-
/// G# assembly annotation so migrated test projects can access internals.
205+
/// Diagnostic-run projects disable SDK AssemblyInfo generation, so the
206+
/// translator must retain only generated friend declarations while the
207+
/// hand-authored fully-qualified attribute stays in its translated source.
206208
/// </summary>
207209
[Fact]
208-
public async Task L2_Translate_PreservesInternalsVisibleTo()
210+
public async Task L2_DiagnosticCompile_EmitsInternalsVisibleToExactlyOnce()
209211
{
210212
string compiler = FindCompiler();
211213
if (compiler is null)
@@ -214,22 +216,113 @@ public async Task L2_Translate_PreservesInternalsVisibleTo()
214216
}
215217

216218
string corpus = ResolveCorpusDir();
217-
string outRoot = NewOutputRoot("l2-friend-assembly");
219+
string repoRoot = GsharpTestProjectRunner.FindRepoRoot();
220+
if (GsharpTestProjectRunner.ResolveLocalSdkPackage(repoRoot) is null)
221+
{
222+
return;
223+
}
224+
225+
string sourceRoot = NewOutputRoot("l2-friend-source");
226+
CorpusApp l2 = CreateFriendAssemblyFixture(corpus, sourceRoot);
227+
string outRoot = NewOutputRoot("l2-friend-diagnostic");
218228
var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot };
219-
var pipeline = new MigrationPipeline(options, new IMigrationStage[] { new TranslateStage() });
229+
var pipeline = new MigrationPipeline(
230+
options,
231+
new IMigrationStage[] { new TranslateStage(), new CompileStage() });
220232

221-
CorpusApp l2 = CorpusDiscovery.FindById(corpus, "corpus/L2-Library");
222233
RunResult result = await pipeline.RunAsync(new[] { l2 });
223234
AppResult app = Assert.Single(result.Apps);
224235

225-
Assert.True(app.Succeeded);
226-
string assemblyInfo = Directory.GetFiles(
227-
Path.Combine(outRoot, result.RunId, MigrationPipeline.SanitizeAppId(l2.Id)),
228-
"AssemblyInfo.gs",
229-
SearchOption.AllDirectories).Single();
236+
Assert.True(app.Succeeded, string.Join(Environment.NewLine, app.Artifacts));
237+
string appDir = Path.Combine(outRoot, result.RunId, MigrationPipeline.SanitizeAppId(l2.Id));
230238
Assert.Equal(
231239
"@assembly:InternalsVisibleTo(\"L2-Library.Tests\")" + Environment.NewLine,
232-
File.ReadAllText(assemblyInfo));
240+
File.ReadAllText(Path.Combine(appDir, "AssemblyInfo.gs")));
241+
Assert.Contains(
242+
"@assembly:System.Runtime.CompilerServices.InternalsVisibleTo(\"L2-Library.Source.Tests\")",
243+
File.ReadAllText(Path.Combine(appDir, "FriendAssembly.gs")),
244+
StringComparison.Ordinal);
245+
246+
string assemblyPath = Path.Combine(appDir, "bin", "Release", "net10.0", "L2-Library.dll");
247+
AssertFriendAssemblies(assemblyPath);
248+
}
249+
250+
/// <summary>
251+
/// Repository projects preserve the MSBuild item and must not receive a
252+
/// second generated G# annotation from translation.
253+
/// </summary>
254+
[Fact]
255+
public async Task L2_RepositoryCompile_EmitsInternalsVisibleToExactlyOnce()
256+
{
257+
string compiler = FindCompiler();
258+
string repoRoot = GsharpTestProjectRunner.FindRepoRoot();
259+
(string NupkgPath, string Version)? sdk =
260+
GsharpTestProjectRunner.ResolveLocalSdkPackage(repoRoot);
261+
if (compiler is null || sdk is null)
262+
{
263+
return;
264+
}
265+
266+
string corpus = ResolveCorpusDir();
267+
string sourceRoot = NewOutputRoot("l2-friend-repository-source");
268+
CorpusApp l2 = CreateFriendAssemblyFixture(corpus, sourceRoot);
269+
string destinationRoot = NewOutputRoot("l2-friend-repository");
270+
string projectDir = Path.Combine(destinationRoot, "L2-Library");
271+
Directory.CreateDirectory(projectDir);
272+
var options = new PipelineOptions
273+
{
274+
GscPath = compiler,
275+
SourceRoot = sourceRoot,
276+
OutputRoot = destinationRoot,
277+
OutputLayout = MigrationOutputLayout.Repository,
278+
};
279+
var context = new StageExecutionContext(
280+
l2,
281+
options,
282+
new GscInvoker(compiler),
283+
projectDir,
284+
new TriageBuilder("run", "2026-07-28T00:00:00Z", "test", l2.Id));
285+
StageOutcome translation = await new TranslateStage().ExecuteAsync(context);
286+
287+
Assert.Equal(StageStatus.Passed, translation.Status);
288+
Assert.False(File.Exists(Path.Combine(projectDir, "AssemblyInfo.gs")));
289+
Assert.Contains(
290+
"@assembly:System.Runtime.CompilerServices.InternalsVisibleTo(\"L2-Library.Source.Tests\")",
291+
File.ReadAllText(Path.Combine(projectDir, "FriendAssembly.gs")),
292+
StringComparison.Ordinal);
293+
294+
string generatedProjectPath = Path.Combine(projectDir, "L2-Library.gsproj");
295+
GSharpProjectTransformer.Transform(
296+
l2.ProjectPath,
297+
projectDir,
298+
"Gsharp.NET.Sdk/" + sdk.Value.Version,
299+
generatedProjectPaths: null).Save(
300+
generatedProjectPath,
301+
System.Xml.Linq.SaveOptions.DisableFormatting);
302+
Assert.Single(
303+
System.Xml.Linq.XDocument.Load(generatedProjectPath).Descendants(),
304+
element =>
305+
element.Name.LocalName == "InternalsVisibleTo" &&
306+
element.Attribute("Include")?.Value == "L2-Library.Tests");
307+
308+
GsharpTestProjectRunner.EnsureInLocalFeed(repoRoot, sdk.Value.NupkgPath);
309+
GsharpTestProjectRunner.WriteIsolationBoundary(projectDir);
310+
ProcessRunResult build = ProcessRunner.Run(
311+
"dotnet",
312+
new[]
313+
{
314+
"build",
315+
generatedProjectPath,
316+
"-c",
317+
"Release",
318+
"-p:RestoreConfigFile=" + Path.Combine(repoRoot, "nuget.config"),
319+
},
320+
projectDir,
321+
TimeSpan.FromMinutes(5));
322+
Assert.True(build.ExitCode == 0, build.Output);
323+
324+
string assemblyPath = Path.Combine(projectDir, "bin", "Release", "net10.0", "L2-Library.dll");
325+
AssertFriendAssemblies(assemblyPath);
233326
}
234327

235328
/// <summary>
@@ -494,6 +587,50 @@ public Task<StageOutcome> ExecuteAsync(StageExecutionContext context, Cancellati
494587
Task.FromResult(StageOutcome.Failed(Array.Empty<TriageArtifact>()));
495588
}
496589

590+
private static CorpusApp CreateFriendAssemblyFixture(string corpus, string sourceRoot)
591+
{
592+
CorpusApp original = CorpusDiscovery.FindById(corpus, "corpus/L2-Library");
593+
string projectDir = Path.Combine(sourceRoot, "L2-Library");
594+
Directory.CreateDirectory(projectDir);
595+
File.Copy(
596+
Path.Combine(corpus, "Directory.Build.props"),
597+
Path.Combine(sourceRoot, "Directory.Build.props"));
598+
string projectPath = Path.Combine(projectDir, Path.GetFileName(original.ProjectPath));
599+
File.Copy(original.ProjectPath, projectPath);
600+
File.WriteAllText(
601+
projectPath,
602+
File.ReadAllText(projectPath).Replace(
603+
"<PropertyGroup>",
604+
"<PropertyGroup>" + Environment.NewLine + " <TargetFramework>net10.0</TargetFramework>",
605+
StringComparison.Ordinal));
606+
File.WriteAllText(Path.Combine(projectDir, "FriendAssembly.cs"), """
607+
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("L2-Library.Source.Tests")]
608+
609+
public sealed class Marker
610+
{
611+
}
612+
""");
613+
return new CorpusApp(
614+
original.Id,
615+
projectPath,
616+
original.TargetKind,
617+
relativeProjectPath: Path.Combine("L2-Library", Path.GetFileName(projectPath)));
618+
}
619+
620+
private static void AssertFriendAssemblies(string assemblyPath)
621+
{
622+
Assembly assembly = Assembly.LoadFile(assemblyPath);
623+
string[] friendAssemblies = assembly.GetCustomAttributesData()
624+
.Where(attribute => attribute.AttributeType == typeof(InternalsVisibleToAttribute))
625+
.Select(attribute => (string)Assert.Single(attribute.ConstructorArguments).Value)
626+
.OrderBy(name => name, StringComparer.Ordinal)
627+
.ToArray();
628+
629+
Assert.Equal(
630+
new[] { "L2-Library.Source.Tests", "L2-Library.Tests" },
631+
friendAssemblies);
632+
}
633+
497634
private static string FingerprintShort(string fingerprint)
498635
{
499636
string hex = fingerprint.StartsWith("sha256:", StringComparison.Ordinal)

tools/cs2gs/Cs2Gs.Tests/TestParityLibraryLiveTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System;
66
using System.Collections.Generic;
77
using System.IO;
8+
using System.Reflection;
9+
using System.Runtime.CompilerServices;
810
using Cs2Gs.Pipeline;
911
using Xunit;
1012

@@ -35,6 +37,12 @@ public class TestParityLibraryLiveTests
3537
" func Subtract(a int, b int) int {\n" +
3638
" return a - b\n" +
3739
" }\n" +
40+
"\n" +
41+
" shared {\n" +
42+
" internal func AddInternal(a int, b int) int {\n" +
43+
" return a + b\n" +
44+
" }\n" +
45+
" }\n" +
3846
"}\n";
3947

4048
private const string TestSource =
@@ -56,6 +64,11 @@ public class TestParityLibraryLiveTests
5664
" Assert.Equal(1, calc.Subtract(3, 2))\n" +
5765
" }\n" +
5866
"\n" +
67+
" @Fact\n" +
68+
" func FriendAssembly_Can_Call_Internal_Method() {\n" +
69+
" Assert.Equal(5, Calculator.AddInternal(2, 3))\n" +
70+
" }\n" +
71+
"\n" +
5972
" @Theory\n" +
6073
" @InlineData(1, 2, 3)\n" +
6174
" @InlineData(2, 2, 4)\n" +
@@ -89,6 +102,7 @@ public void MinimalLibrary_BuildsRunsAndMatchesOracle()
89102
{
90103
new GsharpSourceFile("Calculator.gs", LibrarySource),
91104
},
105+
LibraryFriendAssemblies = new[] { "CalcLib.Tests" },
92106
TestsName = "CalcLib.Tests",
93107
TestsRootNamespace = "CalcLib.Tests",
94108
TestFiles = new List<GsharpSourceFile>
@@ -106,12 +120,20 @@ public void MinimalLibrary_BuildsRunsAndMatchesOracle()
106120
run.Status == GsharpTestRunStatus.Ran,
107121
"Expected a live dotnet test run with a TRX. Status=" + run.Status +
108122
"; output tail:\n" + run.Output);
123+
Assembly library = Assembly.LoadFile(
124+
Path.Combine(workDir, "CalcLib", "bin", "Release", "net10.0", "CalcLib.dll"));
125+
Assert.Single(
126+
library.GetCustomAttributesData(),
127+
attribute =>
128+
attribute.AttributeType == typeof(InternalsVisibleToAttribute) &&
129+
(string)Assert.Single(attribute.ConstructorArguments).Value == "CalcLib.Tests");
109130

110131
var oracle = new[]
111132
{
112133
new TestCaseOutcome("CalcLib.Tests.CalculatorTests.Add_Cases(a: 1, b: 2, expected: 3)", "Passed"),
113134
new TestCaseOutcome("CalcLib.Tests.CalculatorTests.Add_Cases(a: 2, b: 2, expected: 4)", "Passed"),
114135
new TestCaseOutcome("CalcLib.Tests.CalculatorTests.Add_Returns_Sum", "Passed"),
136+
new TestCaseOutcome("CalcLib.Tests.CalculatorTests.FriendAssembly_Can_Call_Internal_Method", "Passed"),
115137
new TestCaseOutcome("CalcLib.Tests.CalculatorTests.Subtract_Returns_Difference", "Passed"),
116138
};
117139

0 commit comments

Comments
 (0)