See README.md for project overview and consumer API.
The snapshot file names under tests/Generator.Tests/Snapshots run to 199
characters, which carries a checkout past the 260 character Windows path limit
unless the clone root is unusually shallow. Git reports Filename too long for
each one, then fatal: unable to checkout working tree, and leaves a
repository with most of its files missing rather than an obvious failure.
git clone -c core.longpaths=true https://github.com/zompinc/sync-method-generator.git-c applies the setting to this clone alone. Setting it globally is worth
doing on any Windows machine which builds .NET repositories, snapshot testing
being common enough that this is not the last repository to hit it:
git config --global core.longpaths trueThe length is mostly intrinsic rather than careless. The generator caps its own generated file names at 100 characters, and Verify prefixes each snapshot with the test class and method names, which puts the floor near 155 before any particular test is named. Renaming tests buys tens of characters; it does not remove the limit.
Consumers installing the Claude Code plugin are unaffected, the documented install command using a sparse checkout which never touches the snapshots.
.claude/settings.json declares this repository as a plugin marketplace and
enables the sync-method-generator plugin at project scope, so Claude Code
offers the skill to anyone working here. The skill lives in
plugins/sync-method-generator/skills/sync-from-async and is the same one
consumers install; editing it and reloading is how to try a change.
Declining the plugin costs nothing. It contributes a skill and no hooks, agents or MCP servers.
# Restore the Node tooling the pre-commit hook needs (once per clone)
pnpm install
# Build
dotnet build
# Test
dotnet test
# Pack (multi-Roslyn-version NuGet package)
./Pack.ps1The project uses a .slnx solution file: Zomp.SyncMethodGenerator.slnx.
Every text file in the repository is CRLF, and the build enforces it. An
ENDOFLINE error fails the build for any .cs file with LF endings, and the
pre-commit hook runs that build, so a commit is rejected rather than merely
flagged.
This bites in ways that are easy to miss:
sed -iunder Git Bash rewrites the whole file with LF. A one line change then shows up as every line changed. Follow anysed -iwithperl -0pi -e 's/(?<!\r)\n/\r\n/g' <file>.- Tools which create files usually write LF. Convert before committing.
git diffagainst a file staged in the other convention shows the entire file as changed. That is the diff, not the edit.
The exception is .husky/, which has its own .gitattributes forcing LF
because the hooks are shell scripts.
The pre-commit hook runs two checks, and CI runs the same hook as a step:
dotnet format --verify-no-changesover the solutionpnpm format:check, which is Prettier over**/*.{yml,yaml,json,md}
Prettier is deliberately scoped to those extensions, with endOfLine: crlf so
it does not fight the convention above. C# formatting belongs to dotnet format and the analyzers. Run pnpm format to fix what it finds.
The generator ships multiple analyzer DLLs targeting different Roslyn versions so the same NuGet package works across .NET 8, 9, and 10 SDKs.
Directory.Build.targets— switchesMicrosoft.CodeAnalysis.CSharpversion viaRoslynVersionMSBuild property and definesROSLYN_X_Y_OR_GREATERconstantssrc/Zomp.SyncMethodGenerator/— the generator project;BaseOutputPathis set per-variant so builds don't collidesrc/Zomp.SyncMethodGenerator.Pack/— packing-only project that gathers pre-built variant DLLs into versionedanalyzers/dotnet/roslyn4.X/cs/NuGet pathsPack.ps1— builds all variants in parallel, then packs
Roslyn variants: roslyn4.8 (4.8.0, .NET 8), roslyn4.12 (4.12.0, .NET 9), roslyn5.0 (5.0.0, .NET 10).
Use #if ROSLYN_X_Y_OR_GREATER guards for APIs that only exist in newer Roslyn versions (e.g., ExtensionBlockDeclarationSyntax is ROSLYN_5_0_OR_GREATER only).
src/Zomp.SyncMethodGenerator/ Generator (netstandard2.0)
SyncMethodSourceGenerator.cs Entry point — IIncrementalGenerator
AsyncToSyncRewriter.cs Async to sync rules, on top of CloningRewriter
SourceGenerationHelper.cs Attribute definitions
Extensions.cs Type-checking extensions on INamedTypeSymbol
DiagnosticMessages.cs ZSMGEN001-006 diagnostic descriptors
Helpers/ CompilationExtensions, LoopExitWalker
Properties/ Assembly attributes
tools/ MSBuild props/targets shipped in the NuGet package
src/Zomp.SyncMethodGenerator.Pack/ Packing-only project (no code)
tests/Generator.Tests/ Unit tests (xUnit + Verify snapshot testing)
tests/GenerationSandbox.Tests/ Integration tests (real-world patterns)
Everything about emitting a copy of a method that has nothing to do with async
comes from the Zomp.MethodCloning source package, maintained in the
zompinc/method-cloning repository: finding the marked methods
(CloneTarget), collecting the namespaces, usings and containing types around
them (MethodLocation), fully qualifying the names a method uses
(CloningRewriter), detecting colliding signatures and naming the files
(ClonedMethodOutput), and writing the file (ClonedMethodSource).
The package ships source, not a DLL. Its files compile into the generator as
internal types in the Zomp.MethodCloning namespace, with the ROSLYN_*
constants this repository defines, so each Roslyn variant gets its own build of
them. A change to them belongs in that repository: fix it there, publish it, and
bump the version in Directory.Packages.props. AsyncToSyncRewriter plugs in
by deriving from CloningRewriter and using its MapSymbol and MapTypeName
hooks.
A cloning bug gets its failing test and its fix in that repository, as a test of its identity clone, whichever generator it was found through. This repository adds a test of its own only when the bug shows up through the async to sync rules. Tests which exercise nothing but cloning - file names, containing types, name qualification, namespaces - live there, not here.
- Find candidates —
CloneTargetusesForAttributeWithMetadataNameto locate[CreateSyncVersion]on methods or types - Extract metadata —
MethodLocationcollects the parent class hierarchy, namespaces and usings; the generator reads the configuration flags - Rewrite —
AsyncToSyncRewriter(aCloningRewriter, which fully qualifies names) traverses the syntax tree:- Strips
asyncmodifier andawaitexpressions - Transforms return types:
Task/ValueTasktovoid,Task<T>/ValueTask<T>toT - Transforms collection types:
IAsyncEnumerable<T>toIEnumerable<T> - Transforms memory types:
Memory<T>toSpan<T>(except in arrays) - Removes
CancellationTokenandIProgress<T>parameters (configurable) - Renames method calls: strips
Asyncsuffix - Handles special methods:
Task.FromResult(x)tox,Task.Delay()toThread.Sleep() - Processes
#if SYNC_ONLY/#if !SYNC_ONLYdirectives
- Strips
- Emit —
ClonedMethodSourcewraps the rewritten method in namespace/class structure, andClonedMethodOutputnames the file and reports colliding signatures
- Snapshot testing with Verify.SourceGenerators — test inputs are inline C# strings, outputs are
.verified.csfiles inSnapshots/ - Test pattern:
[Fact] public Task TestName() => "source code".Verify(); - Snapshot files:
{TestClass}.{TestName}[.Platform].g.verified.cs - Tests compile against real framework assemblies via
TestHelper TestHelperfails a test whose generated code does not compile, before any snapshot is compared, and reports the compiler errorsTestHelperalso clones every synchronized method throughIdentityCloneGenerator, 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 theZomp.MethodCloningpackage, not in the async to sync rules, and is fixed there
Verify takes a SourceType, and the default is the least noisy one:
| SourceType | Wraps the source in | Use for |
|---|---|---|
ClassBody (default) |
namespace Test; partial class Class { ... } |
most tests |
StaticClassBody |
the same, but a static class | extension methods |
MethodBody |
an async Task MethodAsync(CancellationToken ct) inside that class |
a few statements |
Full |
nothing, the source is the whole file | namespaces, using directives, several types |
Prefer the smallest wrapper the test needs, and reach for Full only when the
test genuinely needs file level syntax.
One trap worth knowing: the test compilation has global usings, and global
usings apply to generated files too. A bug about a type resolving through a
file scoped using in the source but not in the generated file therefore
cannot be reproduced with anything but Full - with a smaller wrapper the
global using resolves the type and the bug disappears.
A test with no snapshot, or one whose output changed, writes a .received.
file next to the expected .verified. one. To accept it, copy it over the
verified name and drop the framework infix:
UnitTests.Example.DotNet10_0#Test.Class.MethodAsync.g.received.cs
UnitTests.Example#Test.Class.MethodAsync.g.verified.cs
Output is normally identical across target frameworks, so one verified file
without the infix serves both. Keep the infix only where the frameworks really
differ. Delete stray .received. files before committing; they are ignored by
git but confusing to leave behind.
Land the failing test first, as its own commit, then the fix. The test commit should fail for the reason the issue describes - a compiler error in the generated code, or a snapshot recording the output the issue asks for - so that the fix commit demonstrably changes something. Verify this rather than assuming it: check out the test commit alone and watch it fail.
- Central package management (
Directory.Packages.props) — never put versions in csproj files Microsoft.CodeAnalysis.CSharpis pinned to exactly[5.0.0]. That is not staleness: it sets the lowest Roslyn a consumer needs, and the variant builds override it per variant. Raising it raises the minimum SDK for everyoneTreatWarningsAsErrorsis enabled globally- StyleCop + NetAnalyzers enforced;
.editorconfigdefines style rules - File-scoped namespaces required
- Nerdbank.GitVersioning for version management (from git tags/height)
- The generator targets
netstandard2.0for maximum host compatibility EquatableArray<T>, fromZomp.MethodCloning, wrapsImmutableArray<T>for value equality in the incremental pipeline
| ID | Description |
|---|---|
| ZSMGEN001 | Invalid nesting of SYNC_ONLY directive |
| ZSMGEN002 | SYNC_ONLY mixed with other symbols in #if condition |
| ZSMGEN003 | SYNC_ONLY used with #elif |
| ZSMGEN004 | Task.WhenAll or Task.WhenAny has no sync equivalent |
| ZSMGEN005 | Overloads produce the same signature once synchronized |
| ZSMGEN006 | Synchronized loop never ends (warning) |
A new descriptor must also be listed in AnalyzerReleases.Unshipped.md, or
RS2000 fails the build. An error severity diagnostic suppresses the generated
file for that method, which is usually what you want: emitting code known to be
wrong is worse than emitting nothing.