66using System . IO ;
77using System . Linq ;
88using System . Reflection ;
9+ using System . Runtime . CompilerServices ;
910using System . Runtime . InteropServices ;
1011using System . Text . Json ;
1112using 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 )
0 commit comments