Skip to content

Commit a7e4301

Browse files
author
DavidObando
committed
Preserve friend assemblies across migration layouts
Keep hand-authored InternalsVisibleTo annotations in translated source, preserve SDK-generated declarations only for diagnostic projects, and reproduce generated friend items in repository test parity projects.
1 parent 83c0317 commit a7e4301

6 files changed

Lines changed: 200 additions & 69 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: 19 additions & 7 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,26 +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-
472-
// Documents are loader-classified hand-authored sources; generated obj
473-
// AssemblyInfo trees remain in the compilation but are excluded here.
471+
var handAuthoredTrees = project.Documents
472+
.Select(document => document.SyntaxTree)
473+
.ToHashSet();
474474
List<string> friendAssemblies = project.Compilation.Assembly.GetAttributes()
475475
.Where(attribute =>
476476
attribute.AttributeClass?.ToDisplayString() == attributeName &&
477477
attribute.ConstructorArguments.Length == 1 &&
478478
attribute.ConstructorArguments[0].Value is string &&
479-
attribute.ApplicationSyntaxReference is { SyntaxTree: { } syntaxTree } &&
480-
project.Documents.Any(document => document.SyntaxTree == syntaxTree))
479+
!handAuthoredTrees.Contains(attribute.ApplicationSyntaxReference?.SyntaxTree))
481480
.Select(attribute => (string)attribute.ConstructorArguments[0].Value)
482481
.Distinct(StringComparer.Ordinal)
483482
.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+
484496
if (friendAssemblies.Count == 0)
485497
{
486498
return;

tools/cs2gs/Cs2Gs.Tests/MigrationPipelineTests.cs

Lines changed: 124 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,12 @@ public async Task L2_StagesOneAndTwo_AreGreen_WithZeroArtifacts()
202202
}
203203

204204
/// <summary>
205-
/// SDK-generated and hand-authored friend-assembly declarations must each
206-
/// reach the compiled G# assembly exactly once.
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.
207208
/// </summary>
208209
[Fact]
209-
public async Task L2_Compile_EmitsInternalsVisibleToExactlyOnce()
210+
public async Task L2_DiagnosticCompile_EmitsInternalsVisibleToExactlyOnce()
210211
{
211212
string compiler = FindCompiler();
212213
if (compiler is null)
@@ -216,60 +217,96 @@ public async Task L2_Compile_EmitsInternalsVisibleToExactlyOnce()
216217

217218
string corpus = ResolveCorpusDir();
218219
string repoRoot = GsharpTestProjectRunner.FindRepoRoot();
219-
(string NupkgPath, string Version)? sdk =
220-
GsharpTestProjectRunner.ResolveLocalSdkPackage(repoRoot);
221-
if (sdk is null)
220+
if (GsharpTestProjectRunner.ResolveLocalSdkPackage(repoRoot) is null)
222221
{
223222
return;
224223
}
225224

226-
CorpusApp originalL2 = CorpusDiscovery.FindById(corpus, "corpus/L2-Library");
227225
string sourceRoot = NewOutputRoot("l2-friend-source");
228-
string sourceProjectDir = Path.Combine(sourceRoot, "L2-Library");
229-
Directory.CreateDirectory(sourceProjectDir);
230-
File.Copy(
231-
Path.Combine(corpus, "Directory.Build.props"),
232-
Path.Combine(sourceRoot, "Directory.Build.props"));
233-
string projectPath = Path.Combine(sourceProjectDir, Path.GetFileName(originalL2.ProjectPath));
234-
File.Copy(originalL2.ProjectPath, projectPath);
235-
File.WriteAllText(
236-
projectPath,
237-
File.ReadAllText(projectPath).Replace(
238-
"<PropertyGroup>",
239-
"<PropertyGroup>" + Environment.NewLine + " <TargetFramework>net10.0</TargetFramework>",
240-
StringComparison.Ordinal));
241-
File.WriteAllText(Path.Combine(sourceProjectDir, "FriendAssembly.cs"), """
242-
using System.Runtime.CompilerServices;
243-
244-
[assembly: InternalsVisibleTo("L2-Library.Source.Tests")]
245-
246-
public sealed class Marker
247-
{
248-
}
249-
""");
250-
var l2 = new CorpusApp(
251-
originalL2.Id,
252-
projectPath,
253-
originalL2.TargetKind);
254-
string outRoot = NewOutputRoot("l2-friend-assembly");
226+
CorpusApp l2 = CreateFriendAssemblyFixture(corpus, sourceRoot);
227+
string outRoot = NewOutputRoot("l2-friend-diagnostic");
255228
var options = new PipelineOptions { GscPath = compiler, OutputRoot = outRoot };
256-
var pipeline = new MigrationPipeline(options, new IMigrationStage[] { new TranslateStage() });
229+
var pipeline = new MigrationPipeline(
230+
options,
231+
new IMigrationStage[] { new TranslateStage(), new CompileStage() });
257232

258233
RunResult result = await pipeline.RunAsync(new[] { l2 });
259234
AppResult app = Assert.Single(result.Apps);
260235

261-
Assert.True(app.Succeeded);
236+
Assert.True(app.Succeeded, string.Join(Environment.NewLine, app.Artifacts));
262237
string appDir = Path.Combine(outRoot, result.RunId, MigrationPipeline.SanitizeAppId(l2.Id));
263-
string generatedProjectPath = Path.Combine(appDir, "L2-Library.gsproj");
238+
Assert.Equal(
239+
"@assembly:InternalsVisibleTo(\"L2-Library.Tests\")" + Environment.NewLine,
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");
264295
GSharpProjectTransformer.Transform(
265-
projectPath,
266-
appDir,
296+
l2.ProjectPath,
297+
projectDir,
267298
"Gsharp.NET.Sdk/" + sdk.Value.Version,
268299
generatedProjectPaths: null).Save(
269300
generatedProjectPath,
270301
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+
271308
GsharpTestProjectRunner.EnsureInLocalFeed(repoRoot, sdk.Value.NupkgPath);
272-
GsharpTestProjectRunner.WriteIsolationBoundary(appDir);
309+
GsharpTestProjectRunner.WriteIsolationBoundary(projectDir);
273310
ProcessRunResult build = ProcessRunner.Run(
274311
"dotnet",
275312
new[]
@@ -280,21 +317,12 @@ public sealed class Marker
280317
"Release",
281318
"-p:RestoreConfigFile=" + Path.Combine(repoRoot, "nuget.config"),
282319
},
283-
appDir,
320+
projectDir,
284321
TimeSpan.FromMinutes(5));
285322
Assert.True(build.ExitCode == 0, build.Output);
286323

287-
string assemblyPath = Path.Combine(appDir, "bin", "Release", "net10.0", "L2-Library.dll");
288-
Assembly assembly = Assembly.LoadFile(assemblyPath);
289-
string[] friendAssemblies = assembly.GetCustomAttributesData()
290-
.Where(attribute => attribute.AttributeType == typeof(InternalsVisibleToAttribute))
291-
.Select(attribute => (string)Assert.Single(attribute.ConstructorArguments).Value)
292-
.OrderBy(name => name, StringComparer.Ordinal)
293-
.ToArray();
294-
295-
Assert.Equal(
296-
new[] { "L2-Library.Source.Tests", "L2-Library.Tests" },
297-
friendAssemblies);
324+
string assemblyPath = Path.Combine(projectDir, "bin", "Release", "net10.0", "L2-Library.dll");
325+
AssertFriendAssemblies(assemblyPath);
298326
}
299327

300328
/// <summary>
@@ -559,6 +587,50 @@ public Task<StageOutcome> ExecuteAsync(StageExecutionContext context, Cancellati
559587
Task.FromResult(StageOutcome.Failed(Array.Empty<TriageArtifact>()));
560588
}
561589

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+
562634
private static string FingerprintShort(string fingerprint)
563635
{
564636
string hex = fingerprint.StartsWith("sha256:", StringComparison.Ordinal)

0 commit comments

Comments
 (0)