From 77062db96b2cfaae225c0cc134809e336e62af30 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 24 May 2026 15:11:27 +1200 Subject: [PATCH] feat(shims): suppress SHIM001 for consumer-bridged types (#69 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TransitionShimGenerator pre-pass now walks the consuming compilation's own namespaces and collects FQNs of every top-level public type under the target shim prefix. When the canonical-side walk would emit (or warn-skip) a type whose target FQN already exists in that set, the generator silently defers to the hand-written bridge — no source emitted, no SHIM001. Drops 20 of the live SHIM001 warnings on the Nuke.Common shim build (the 10 CI host singletons × 2 referenced-assembly paths from session 4). Remaining SHIM001 counts: 76 enum, 14 delegate, 8 no-accessible-ctor (4 unique: AbsolutePath, AzureKeyVault, MSBuildProject, DelegateDisposable), 4 struct, 4 ambiguous-across-assemblies, 2 sealed-class (GlobbingOptionsAttribute). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TransitionShimGenerator.cs | 46 +++++++++++++++++-- .../TransitionShimGeneratorTest.cs | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/Fallout.SourceGenerators/TransitionShimGenerator.cs b/src/Fallout.SourceGenerators/TransitionShimGenerator.cs index c9136841d..608d88ac2 100644 --- a/src/Fallout.SourceGenerators/TransitionShimGenerator.cs +++ b/src/Fallout.SourceGenerators/TransitionShimGenerator.cs @@ -129,6 +129,14 @@ private static void EmitShimsForMarker(SourceProductionContext ctx, Compilation fqnCounts.Where(kv => kv.Value > 1).Select(kv => kv.Key), StringComparer.Ordinal); + // Pre-pass: collect FQNs of every top-level public type the consuming + // compilation already declares under the target shim prefix. A hand- + // written bridge at the target FQN is the consumer's authoritative + // answer — the generator skips that canonical type silently (no + // emission, no SHIM001). + var handBridged = new HashSet(StringComparer.Ordinal); + CollectHandBridgedFqns(compilation.SourceModule.GlobalNamespace, marker.ToPrefix, handBridged); + // The same logical type can be reached via multiple referenced assemblies. // Dedupe hint names so AddSource doesn't throw. var emittedHints = new HashSet(StringComparer.Ordinal); @@ -140,8 +148,28 @@ private static void EmitShimsForMarker(SourceProductionContext ctx, Compilation if (!assemblyRef.Name.StartsWith("Fallout.", StringComparison.Ordinal)) continue; - VisitNamespace(ctx, assemblyRef.GlobalNamespace, marker, emittedHints, ambiguous); + VisitNamespace(ctx, assemblyRef.GlobalNamespace, marker, emittedHints, ambiguous, handBridged); + } + } + + private static void CollectHandBridgedFqns(INamespaceSymbol ns, string toPrefix, HashSet sink) + { + var fullNs = ns.ToDisplayString(); + var inScope = !string.IsNullOrEmpty(fullNs) + && (fullNs == toPrefix || fullNs.StartsWith(toPrefix + ".", StringComparison.Ordinal)); + if (inScope) + { + foreach (var type in ns.GetTypeMembers()) + { + if (type.DeclaredAccessibility != Accessibility.Public) continue; + // Compose the metadata-style FQN: namespace + "." + MetadataName. + // MetadataName encodes arity (e.g. `Foo`1` for `Foo`), so this + // matches arity correctly when we look it up later. + sink.Add(fullNs + "." + type.MetadataName); + } } + foreach (var child in ns.GetNamespaceMembers()) + CollectHandBridgedFqns(child, toPrefix, sink); } private static void CountFqnsInNamespace(INamespaceSymbol ns, ShimMarker marker, Dictionary counts) @@ -161,7 +189,7 @@ private static void CountFqnsInNamespace(INamespaceSymbol ns, ShimMarker marker, CountFqnsInNamespace(child, marker, counts); } - private static void VisitNamespace(SourceProductionContext ctx, INamespaceSymbol ns, ShimMarker marker, HashSet emittedHints, HashSet ambiguousFqns) + private static void VisitNamespace(SourceProductionContext ctx, INamespaceSymbol ns, ShimMarker marker, HashSet emittedHints, HashSet ambiguousFqns, HashSet handBridgedFqns) { foreach (var type in ns.GetTypeMembers()) { @@ -176,19 +204,27 @@ private static void VisitNamespace(SourceProductionContext ctx, INamespaceSymbol || fullNamespace.StartsWith(marker.FromPrefix + ".", StringComparison.Ordinal); if (!matches) continue; - EmitOrSkipType(ctx, type, marker, emittedHints, ambiguousFqns); + EmitOrSkipType(ctx, type, marker, emittedHints, ambiguousFqns, handBridgedFqns); } foreach (var child in ns.GetNamespaceMembers()) - VisitNamespace(ctx, child, marker, emittedHints, ambiguousFqns); + VisitNamespace(ctx, child, marker, emittedHints, ambiguousFqns, handBridgedFqns); } - private static void EmitOrSkipType(SourceProductionContext ctx, INamedTypeSymbol type, ShimMarker marker, HashSet emittedHints, HashSet ambiguousFqns) + private static void EmitOrSkipType(SourceProductionContext ctx, INamedTypeSymbol type, ShimMarker marker, HashSet emittedHints, HashSet ambiguousFqns, HashSet handBridgedFqns) { // Skip nested types at top level — they get emitted inside their // declaring shim's source. if (type.ContainingType is not null) return; + // If the consumer hand-wrote a shim at the target FQN, treat that as the + // authoritative bridge. Skip both emission and the SHIM001 diagnostic — + // the canonical type is covered, just not by us. + var targetNs = SwapNamespace(type.ContainingNamespace.ToDisplayString(), marker); + var targetMetadataFqn = targetNs + "." + type.MetadataName; + if (handBridgedFqns.Contains(targetMetadataFqn)) + return; + var fqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); if (ambiguousFqns.Contains(fqn)) { diff --git a/tests/Fallout.SourceGenerators.Tests/TransitionShimGeneratorTest.cs b/tests/Fallout.SourceGenerators.Tests/TransitionShimGeneratorTest.cs index 8c93d4638..87fec9c04 100644 --- a/tests/Fallout.SourceGenerators.Tests/TransitionShimGeneratorTest.cs +++ b/tests/Fallout.SourceGenerators.Tests/TransitionShimGeneratorTest.cs @@ -72,6 +72,52 @@ public static void VoidReturn(string s) { } return Verifier.Verify(result); } + // Hand-bridge suppression: when the consuming compilation declares a type at + // the target shim FQN, the generator treats that as the authoritative bridge — + // no emission, no SHIM001 (even for canonical kinds that would otherwise be + // skipped as Hard tier). Mirrors the session-4 CI host pattern in Nuke.Common. + [Fact] + public void SkipsCanonicalTypesAlreadyHandBridgedByConsumer() + { + var canonical = CompileCanonicalAssembly(""" + namespace Fallout.Common + { + public sealed class HandBridgedSealed { public HandBridgedSealed() {} } + public class HandBridgedRegular { public HandBridgedRegular() {} } + } + """); + + var shimCompilation = CSharpCompilation.Create("Nuke.HandBridged", + new[] + { + CSharpSyntaxTree.ParseText(""" + [assembly: Fallout.Migrate.Shims.ShimAllPublicTypesUnder("Fallout.Common", "Nuke.Common")] + """), + CSharpSyntaxTree.ParseText(""" + namespace Nuke.Common + { + public static class HandBridgedSealed { } + public static class HandBridgedRegular { } + } + """), + }, + Basic.Reference.Assemblies.NetStandard20.References.All + .Concat(new[] { canonical }), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver.Create(new TransitionShimGenerator()); + var result = driver.RunGenerators(shimCompilation).GetRunResult(); + + // No source emitted for the hand-bridged canonical types. + result.GeneratedTrees + .Where(t => !t.FilePath.EndsWith("ShimAllPublicTypesUnderAttribute.g.cs", System.StringComparison.Ordinal)) + .Should().BeEmpty(); + + // And no SHIM001 diagnostic for either hand-bridged type — the sealed + // one would normally warn, the regular one would normally emit. + result.Diagnostics.Should().BeEmpty(); + } + [Fact] public void EmitsNothingWhenNoMarkerAttributePresent() {