From 58ac680ac598c443e57ee45f4ecc7c1709924ee5 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sat, 12 Sep 2026 12:54:46 -0400 Subject: [PATCH] Check that every test input clones without changes Every test now also runs an identity transformation over its source: IdentityCloneGenerator copies each method the sync generator would synchronize through IdentityRewriter, a CloningRewriter which changes nothing but the method's name, and the test fails if a copy does not compile. A failure there is in the Cloning layer - qualification, usings, containing types, directives - rather than in the async to sync rules, which the snapshot tests cannot tell apart. The pass also fails when it clones fewer methods than were synchronized, so that cloning nothing cannot pass for cloning correctly. The generator exposes its internals to Generator.Tests for this. Generated with Claude Code --- AGENTS.md | 4 ++ .../Zomp.SyncMethodGenerator.csproj | 5 ++ .../Generator.Tests/IdentityCloneGenerator.cs | 68 +++++++++++++++++++ tests/Generator.Tests/IdentityRewriter.cs | 35 ++++++++++ tests/Generator.Tests/TestHelper.cs | 47 +++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 tests/Generator.Tests/IdentityCloneGenerator.cs create mode 100644 tests/Generator.Tests/IdentityRewriter.cs diff --git a/AGENTS.md b/AGENTS.md index 83743a8..d49e158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,10 @@ come back empty. - Tests compile against real framework assemblies via `TestHelper` - `TestHelper` fails a test whose generated code does not compile, before any snapshot is compared, and reports the compiler errors +- `TestHelper` also clones every synchronized method through + `IdentityCloneGenerator`, which changes nothing but the method's name, and + fails the test with "Identity clone does not compile" when a clone does not. + That failure is in the `Cloning/` layer, not in the async to sync rules ### Choosing how the source is wrapped diff --git a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj index 38aa6d0..a8c2e83 100644 --- a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj +++ b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj @@ -54,6 +54,11 @@ + + + + + diff --git a/tests/Generator.Tests/IdentityCloneGenerator.cs b/tests/Generator.Tests/IdentityCloneGenerator.cs new file mode 100644 index 0000000..c9f1039 --- /dev/null +++ b/tests/Generator.Tests/IdentityCloneGenerator.cs @@ -0,0 +1,68 @@ +using Zomp.SyncMethodGenerator; +using Zomp.SyncMethodGenerator.Cloning; + +namespace Generator.Tests; + +/// +/// Clones every method the sync generator would synchronize, through , +/// which changes nothing but its name. A clone which does not compile points at the cloning layer +/// rather than at the async to sync rules. +/// +internal sealed class IdentityCloneGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var disableNullable = context.CompilationProvider.Select(static (c, _) => + c.Options.NullableContextOptions == NullableContextOptions.Disable + || c is CSharpCompilation { LanguageVersion: < LanguageVersion.CSharp8 }); + + var methods = CloneTarget.ForAttribute(context.SyntaxProvider, SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute) + .Combine(disableNullable) + .Select(static (data, ct) => Clone(data.Left, data.Right, ct)!) + .Where(static m => m is not null); + + ClonedMethodOutput.Register(context, methods, Zomp.SyncMethodGenerator.DiagnosticMessages.CollidingOverloads); + } + + private static ClonedMethod? Clone(CloneTarget target, bool disableNullable, CancellationToken ct) + { + var context = target.Context; + var isTargetTypeSymbol = context.TargetSymbol is ITypeSymbol; + + var symbol = isTargetTypeSymbol + ? context.SemanticModel.GetDeclaredSymbol(target.Syntax, ct) + : context.TargetSymbol as IMethodSymbol; + + // The same selection the sync generator makes, so that only the methods it copies are cloned. + if (symbol is null + || (!symbol.IsAsync + && (symbol.ReturnType is not INamedTypeSymbol named || !AsyncToSyncRewriter.IsTypeOfInterest(named)))) + { + return null; + } + + foreach (var attribute in symbol.GetAttributes()) + { + var name = attribute.AttributeClass?.ToDisplayString(); + + if (name == SyncMethodSourceGenerator.QualifiedSkipSyncVersionAttribute + || (isTargetTypeSymbol && name == SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute)) + { + return null; + } + } + + if (!MethodLocation.TryCreate(target.Syntax, symbol, out var location, out var root)) + { + return null; + } + + disableNullable |= context.Attributes[0].NamedArguments + .Any(static a => a is { Key: SyncMethodSourceGenerator.OmitNullableDirective, Value.Value: true }); + + var rewritten = new IdentityRewriter(context.SemanticModel, target.Syntax).Visit(root); + + return ClonedMethod.Create(location, target.Syntax, rewritten, disableNullable, []); + } +} diff --git a/tests/Generator.Tests/IdentityRewriter.cs b/tests/Generator.Tests/IdentityRewriter.cs new file mode 100644 index 0000000..459bab3 --- /dev/null +++ b/tests/Generator.Tests/IdentityRewriter.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Zomp.SyncMethodGenerator.Cloning; + +namespace Generator.Tests; + +/// +/// A transformation which changes nothing but the name of the method, leaving everything else +/// to . +/// +/// The semantic model. +/// The method declaration to clone. +internal sealed class IdentityRewriter(SemanticModel semanticModel, MethodDeclarationSyntax targetMethod) + : CloningRewriter(semanticModel, targetMethod) +{ + /// + /// Appended to the name of each method, so that the clone does not collide with the original. + /// + internal const string Suffix = "Clone"; + + /// + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + if (base.VisitMethodDeclaration(node) is not MethodDeclarationSyntax clone) + { + return null; + } + + var name = SyntaxFactory.Identifier(node.Identifier.ValueText + Suffix).WithTriviaFrom(clone.Identifier); + + // Directives leading the method belong to the file it was written in. + return clone + .WithIdentifier(name) + .WithLeadingTrivia(RemovePreprocessorDirectives(clone.GetLeadingTrivia())); + } +} diff --git a/tests/Generator.Tests/TestHelper.cs b/tests/Generator.Tests/TestHelper.cs index 900cc9d..f5be7f4 100644 --- a/tests/Generator.Tests/TestHelper.cs +++ b/tests/Generator.Tests/TestHelper.cs @@ -134,6 +134,8 @@ namespace Test; "Compilation errors:\n" + string.Join("\n", diagnostics.Select(d => d.ToString()))); } + EnsureIdentityClonesCompile(compilation, parseOptions); + var target = new RunResultWithIgnoreList { Result = driver.GetRunResult(), @@ -177,6 +179,51 @@ internal static string ChangeIndentation(string source, Func fun return linesWithIndentation; } + /// + /// Clones every method the test synchronizes, changing nothing but its name, and fails the + /// test when a clone does not compile. Such a failure is in the cloning layer, whatever the + /// test itself is about. + /// + private static void EnsureIdentityClonesCompile(CSharpCompilation compilation, CSharpParseOptions parseOptions) + { + // The sync generator runs as well, since it declares the attributes the source uses. + var result = CSharpGeneratorDriver.Create(new SyncMethodSourceGenerator(), new IdentityCloneGenerator()) + .WithUpdatedParseOptions(parseOptions) + .RunGenerators(compilation) + .GetRunResult(); + + var identity = result.Results.Single(r => r.Generator.GetGeneratorType() == typeof(IdentityCloneGenerator)); + + if (identity.Exception is { } exception) + { + throw new InvalidOperationException("Identity clone threw", exception); + } + + // Every method which is synchronized is cloned as well, so that a pass which cloned + // nothing cannot pass for one whose clones compile. + var synchronized = result.Results + .Single(r => r.Generator.GetGeneratorType() == typeof(SyncMethodSourceGenerator)) + .GeneratedSources + .Count(s => s.HintName is not ($"{SyncMethodSourceGenerator.CreateSyncVersionAttribute}.g.cs" or $"{SyncMethodSourceGenerator.SkipSyncVersionAttribute}.g.cs")); + + if (identity.GeneratedSources.Length < synchronized) + { + throw new InvalidOperationException( + $"Identity clone produced {identity.GeneratedSources.Length} files for {synchronized} synchronized methods"); + } + + var errors = identity.Diagnostics + .Concat(compilation.AddSyntaxTrees(result.GeneratedTrees).GetDiagnostics()) + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + + if (errors.Length > 0) + { + throw new InvalidOperationException( + "Identity clone does not compile:\n" + string.Join("\n", errors.Select(d => d.ToString()))); + } + } + #if NET8_0_OR_GREATER [GeneratedRegex("\r\n")] private static partial Regex NewLineRegex();