From 431a5f643303c4058c0906343f9ad7a21d3562c0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 12 Aug 2026 16:34:37 -0400 Subject: [PATCH] test(scripts): pin that repeat compiles resolve the assembly closure zero times --- .../CachingScriptMetadataResolverTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs index b35b1c8d..f2b0be07 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs @@ -89,4 +89,66 @@ public class CachingScriptMetadataResolverTests var sut = new CachingScriptMetadataResolver(new CountingResolver()); Assert.True(sut.ResolveMissingAssemblies); } + + /// + /// The decisive regression: with options shaped like the real compile + /// surfaces (small explicit set, large transitive closure), the FIRST + /// compile resolves the closure through the resolver and every subsequent + /// compile — same or different code — resolves NOTHING. Before the fix, + /// every compile re-resolved the full closure (measured: 74 fresh + /// PortableExecutableReferences per compile on Roslyn 5.0.0). + /// A probe wraps the DEFAULT resolver so the test also proves resolution + /// actually flows through this path at all (first > 0) — guarding against + /// the whole mechanism silently changing in a Roslyn upgrade. + /// + [Fact] + public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce() + { + var probe = new ProbeResolver(ScriptOptions.Default.MetadataResolver); + var caching = new CachingScriptMetadataResolver(probe); + + var options = ScriptOptions.Default + .WithReferences( + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly) + .WithImports("System", "System.Linq") + .WithMetadataResolver(caching); + + long CompileAndCount(string code) + { + long before = probe.MissingCalls; + var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create(code, options); + var errors = script.Compile().Count(d => d.Severity == DiagnosticSeverity.Error); + Assert.Equal(0, errors); + return probe.MissingCalls - before; + } + + var first = CompileAndCount("return Enumerable.Range(1, 3).Sum();"); + var repeat = CompileAndCount("return Enumerable.Range(1, 3).Sum();"); + var distinct = CompileAndCount("return string.Join(\",\", Enumerable.Range(1, 2)).Length;"); + + Assert.True(first > 0, "expected the first compile to resolve the transitive closure through the resolver"); + Assert.Equal(0, repeat); // pre-fix: == first (~74) — one fresh native metadata copy per assembly per compile + Assert.Equal(0, distinct); + } + + private sealed class ProbeResolver : MetadataReferenceResolver + { + private readonly MetadataReferenceResolver _inner; + public long MissingCalls; + public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner; + public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies; + public override PortableExecutableReference? ResolveMissingAssembly( + MetadataReference definition, AssemblyIdentity referenceIdentity) + { + Interlocked.Increment(ref MissingCalls); + return _inner.ResolveMissingAssembly(definition, referenceIdentity); + } + public override ImmutableArray ResolveReference( + string reference, string? baseFilePath, MetadataReferenceProperties properties) + => _inner.ResolveReference(reference, baseFilePath, properties); + public override bool Equals(object? other) => ReferenceEquals(this, other); + public override int GetHashCode() => 0; + } }