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();