From deb3033a5f0231b953a2cb39bf555816e38b3ed1 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Thu, 27 Aug 2026 01:10:23 -0400 Subject: [PATCH 1/2] Ship a Claude Code plugin alongside the package This generator only helps someone who already knows it exists. An agent writing both halves of a sync/async pair by hand has no reason to go looking for it, so the repository now doubles as a Claude Code marketplace. Installing the plugin puts a skill in front of the agent which fires on the shape of the problem rather than on the library's name. The skill leads with the screening criteria that decide whether a pair is a candidate at all - both halves in the same type, a partial declaration, a sync half which is a mechanical translation of the async one - because those are what a first migration gets wrong. Setup, the attribute options and the SYNC_ONLY directive follow. Two references carry the full transformation table and the migration workflow, the latter drawn from the SharpCompress and SSH.NET conversions: capture before deleting, normalise the global:: qualification precisely enough that the comparison means something, and watch for a generated method which adds a member rather than removing a duplicate. Generated with Claude Code --- .claude-plugin/marketplace.json | 15 ++ README.md | 11 ++ plugins/sync-method-generator/plugin.json | 12 ++ .../skills/sync-from-async/SKILL.md | 152 ++++++++++++++++++ .../references/migrating-existing-twins.md | 66 ++++++++ .../references/transformations.md | 65 ++++++++ 6 files changed, 321 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/sync-method-generator/plugin.json create mode 100644 plugins/sync-method-generator/skills/sync-from-async/SKILL.md create mode 100644 plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md create mode 100644 plugins/sync-method-generator/skills/sync-from-async/references/transformations.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..5657912 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "sync-method-generator", + "description": "Skills for Zomp.SyncMethodGenerator, the .NET source generator which writes the sync half of a sync/async method pair", + "owner": { + "name": "Zomp" + }, + "plugins": [ + { + "name": "sync-method-generator", + "source": "./plugins/sync-method-generator", + "description": "Generate the sync half of a C# sync/async method pair with Zomp.SyncMethodGenerator instead of hand-maintaining both", + "version": "0.1.0" + } + ] +} diff --git a/README.md b/README.md index fad1f1b..952aa78 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,17 @@ To add the library use: dotnet add package Zomp.SyncMethodGenerator ``` +### Claude Code plugin + +This repository is also a [Claude Code](https://claude.com/claude-code) marketplace. Installing the plugin teaches the agent to reach for this generator when it finds itself writing or maintaining both halves of a sync/async pair, rather than duplicating the method by hand. + +```sh +claude plugin marketplace add zompinc/sync-method-generator +claude plugin install sync-method-generator@sync-method-generator +``` + +The plugin ships a single skill covering setup, the attribute options, the transformation table, and how to verify that a generated method is the hand-written one it replaces. + ## Development ### Related projects diff --git a/plugins/sync-method-generator/plugin.json b/plugins/sync-method-generator/plugin.json new file mode 100644 index 0000000..7c4de7e --- /dev/null +++ b/plugins/sync-method-generator/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "sync-method-generator", + "version": "0.1.0", + "description": "Generate the sync half of a C# sync/async method pair with Zomp.SyncMethodGenerator instead of hand-maintaining both", + "author": { + "name": "Zomp" + }, + "homepage": "https://github.com/zompinc/sync-method-generator", + "license": "MIT", + "keywords": ["dotnet", "csharp", "async", "source-generator", "roslyn"], + "skills": "./skills" +} diff --git a/plugins/sync-method-generator/skills/sync-from-async/SKILL.md b/plugins/sync-method-generator/skills/sync-from-async/SKILL.md new file mode 100644 index 0000000..4cbd7f8 --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/SKILL.md @@ -0,0 +1,152 @@ +--- +name: sync-from-async +description: > + Generate the synchronous half of a C# method pair from the async half with the + Zomp.SyncMethodGenerator source generator, instead of hand-writing and maintaining both. + Use whenever a .NET type needs sync and async versions of the same method: adding a sync + overload beside an async one, editing a file where `Foo` and `FooAsync` share a body, or + removing the duplication between hand-written twins. Also use when asked to "write the sync + version of this method", "keep sync and async in step", or "stop duplicating sync and + async". DO NOT USE FOR: turning sync code async, calling async code from a sync context + (`GetAwaiter().GetResult()`), or languages other than C#. +--- + +# Sync methods generated from async ones + +`Zomp.SyncMethodGenerator` is a Roslyn source generator. You write the async method, attribute +it, and the generator emits the sync twin into a partial of the same type: `await` and `async` +removed, `Task` unwrapped to `T`, `IAsyncEnumerable` to `IEnumerable`, +`ReadOnlyMemory` to `ReadOnlySpan`, `CancellationToken` and `IProgress` parameters +dropped, `ConfigureAwait` calls removed, and `FooAsync()` invocations rewritten to `Foo()`. + +```cs +[Zomp.SyncMethodGenerator.CreateSyncVersion] +static async Task WriteAsync(ReadOnlyMemory buffer, Stream stream, CancellationToken ct) + => await stream.WriteAsync(buffer, ct).ConfigureAwait(false); + +// generated: +static void Write(ReadOnlySpan buffer, Stream stream) + => stream.Write(buffer); +``` + +The point is that the two halves cannot drift. A bug fixed in the async method is fixed in the +sync one at the next build. + +## Check that it fits before proposing it + +Run through this first. The generator is a good answer for most sync/async pairs and a bad +answer for a few, and the bad cases are cheaper to spot now than after a migration. + +1. **Both halves must live in the same type.** The generated method lands in a partial of the + type declaring the async method. If the hand-written sync version lives in a different class + (a `FooSync` helper, a separate `SyncExtensions`), the generator adds a member rather than + replacing one. Not a fit without moving code first. +1. **The type must be `partial`**, along with every type enclosing it. +1. **The sync half must be a mechanical translation of the async half.** If it uses a different + algorithm, different locking, a sync-only fast path, or different error handling, either + express the difference with `SYNC_ONLY` (see below) or leave the pair alone. Do not flatten a + real behavioural difference into a generated method. +1. **The async method should be named `FooAsync`.** The generated name is the source name with + the `Async` suffix removed; without the suffix the generated method collides with the + original and the build fails. +1. **Watch for a generated method that adds a member instead of removing a duplicate.** If the + sync signature you are about to generate is already satisfied by a base class or interface + shim, generating it changes which member callers bind to. That is a behaviour change, not a + deduplication. See [references/migrating-existing-twins.md](references/migrating-existing-twins.md). + +## Set it up + +```sh +dotnet add package Zomp.SyncMethodGenerator +``` + +`PrivateAssets="all"` keeps the generator out of the package your library ships: + +```xml + +``` + +With Central Package Management, the version goes in `Directory.Packages.props`. If most +projects in the solution need it, a single `GlobalPackageReference` is tidier than a +`PackageReference` per project - it carries `PrivateAssets="all"` implicitly: + +```xml + +``` + +The generator targets .NET Standard 2.0 and needs no runtime dependency on the consuming side. + +## Write the async method + +Attribute a single method, or attribute the type to generate for every async method in it: + +```cs +[Zomp.SyncMethodGenerator.CreateSyncVersion] +partial class Reader +{ + async Task ReadAsync(...) { ... } + + [Zomp.SyncMethodGenerator.SkipSyncVersion] + async Task NoSyncCounterpartAsync(...) { ... } +} +``` + +Attribute properties, all defaulting to `false`: + +| Property | Effect | +| --------------------------- | --------------------------------------------------------------------------- | +| `PreserveCancellationToken` | Keeps `CancellationToken` parameters instead of dropping them | +| `PreserveProgress` | Keeps `IProgress` parameters and `Report` calls instead of dropping them | +| `OmitNullableDirective` | Suppresses the `#nullable enable` the generator emits on C# 8 and above | + +XML documentation is carried over and adjusted: a `` block is dropped when the sync +method returns nothing, and the source file's `using` directives are carried into the generated +file so `cref`s still resolve. + +The full transformation table is in +[references/transformations.md](references/transformations.md). Read it when you need to know +whether a particular construct survives the rewrite. + +## Code that should only run in one half + +Wrap it in a `SYNC_ONLY` conditional. The symbol must never actually be defined anywhere - the +generator reads the directive rather than the compiler: + +```cs +[Zomp.SyncMethodGenerator.CreateSyncVersion] +public async Task FlushAsync(CancellationToken ct) +{ +#if SYNC_ONLY + Thread.Sleep(BackoffMilliseconds); +#endif + await Task.CompletedTask; +} +``` + +`#if !SYNC_ONLY` marks async-only code. The block is copied verbatim, so fully qualify anything +that relies on a `using` the generated file might not have. `SYNC_ONLY` cannot be combined with +other symbols in one condition and has no `#elif`. + +## Verify what came out + +Do not assume the generated method is what you expected - read it. Turn on emission: + +```xml +true +``` + +Build, then find the file under +`obj///generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/`. +It is named `...g.cs`. + +When replacing a hand-written sync method, compare the two mechanically rather than by eye. +[references/migrating-existing-twins.md](references/migrating-existing-twins.md) has the +workflow, including how to normalise the generator's `global::` qualification so the comparison +means something. + +## When the output is wrong + +The rewriter handles a large surface but not an unbounded one. If the generated method does not +compile or does not match the hand-written one for a reason you cannot explain, that is worth an +issue at https://github.com/zompinc/sync-method-generator/issues rather than a workaround - +several of the released fixes came from exactly this, reported by libraries adopting it. diff --git a/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md b/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md new file mode 100644 index 0000000..d7882ba --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md @@ -0,0 +1,66 @@ +# Replacing hand-written twins with generated ones + +Adding the generator to a new method is low risk: nothing existed before, and the compiler +checks the result. Replacing a sync method somebody wrote and shipped is different. The +generated method has to be the method it replaces, and "looks about right" is not evidence. + +SharpCompress wrote up its migration in +[docs/SYNC_METHOD_GENERATION.md](https://github.com/adamhathcock/sharpcompress/blob/master/docs/SYNC_METHOD_GENERATION.md). +Worth reading before a first migration in an established codebase. + +## Order of work + +The generated partial and the hand-written method have the same signature, so they cannot +coexist - keeping both is a duplicate member error. That means capturing the original before +deleting it. + +1. **Capture the hand-written sync method.** Copy its full text somewhere outside the tree, or + rely on `git show HEAD:` after the deletion. +1. **Delete the hand-written sync method** and attribute the async one. +1. **Build with emission on** so the generated file lands on disk: + ```xml + true + ``` + Output goes to + `obj///generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/...g.cs`. +1. **Compare the generated method against the captured one**, normalised (see below). +1. **Explain every difference before accepting it.** A difference is either a bug in the + generator, a real behavioural difference that was hiding in the pair, or something the + original got wrong. All three are worth knowing about. None of them are noise. + +## Normalising the comparison + +A direct `diff` is dominated by two differences that do not matter, and hides the ones that do: + +- **`global::` qualification.** The generator fully qualifies types. Strip the prefix before + comparing - but strip it precisely. A regex like `s/global::[A-Za-z.]*\.//` is over-greedy and + will eat parts of expressions such as `ArgumentException.ThrowIfNull`, producing a clean diff + that proves nothing. +- **Whitespace and line breaks.** The generator formats from the syntax tree, so wrapping + differs from what a human typed. + +Compare token sequences, or normalise whitespace to single spaces and drop the qualification +prefix only where it directly precedes a type name. Then require exact equality. On a real +migration of ten methods, "ten of ten identical after normalisation" is the result worth +reporting; anything less needs a per-method explanation. + +## Generating a member instead of removing one + +The trap that does not show up as a diff. Before attributing a method, check what the sync +signature would bind to today. + +SharpCompress hit this with `Read(Span)`. Generating it from `ReadAsync(Memory)` did +not deduplicate anything - `Stream` already provides a `Read(Span)` shim that routes to +`Read(byte[], int, int)`. The generated override displaced the shim, which is a change in +behaviour for every caller, not a cleanup. It may well be an improvement, but it is a separate +decision from removing duplication and should be made deliberately. + +Ask, for each method: is there an existing base class or interface member with this exact +signature? If yes, the generated method is an override, and the change is about behaviour. + +## Scope of a first pull request + +Maintainers of established libraries are being asked to accept generated code into a build they +own. A first change that converts five to ten methods in one file, with the verification shown, +is easier to accept than one that sweeps a whole project. Save the wide sweep for after the +approach has been agreed. diff --git a/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md b/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md new file mode 100644 index 0000000..95203fa --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md @@ -0,0 +1,65 @@ +# What the rewriter changes + +Reference for deciding whether a given async method translates cleanly. Source of truth is the +project README and the snapshot tests under `tests/Generator.Tests/Snapshots/`. + +## Declaration + +- The `async` modifier is removed. +- The `Async` suffix is removed from the method name. +- The `CreateSyncVersionAttribute` is removed from the generated method. +- `#nullable enable` is emitted when the language version is 8 or above, unless + `OmitNullableDirective` is set. + +## Types + +| From | To | +| ----------------------------------------------------------- | ------------------------------ | +| `Task`, `ValueTask` | `void` | +| `Task`, `ValueTask` | `T` | +| `Func` | `Action` | +| `Func>` | `Func` | +| `IAsyncEnumerable` | `IEnumerable` | +| `IAsyncEnumerator` | `IEnumerator` | +| `ConfiguredCancelableAsyncEnumerable.Enumerator` | `IEnumerator` | +| `ConfiguredCancelableAsyncEnumerable.GetAsyncEnumerator` | `IEnumerable.GetEnumerator` | +| `Memory` | `Span` | +| `ReadOnlyMemory` | `ReadOnlySpan` | + +`Memory` and `ReadOnlyMemory` are left alone when they appear as a type argument of a +collection - a `ref struct` cannot be an array element type, so the substitution would not +compile. + +## Parameters + +- `CancellationToken` parameters are removed, unless `PreserveCancellationToken` is set. +- `IProgress` parameters are removed, unless `PreserveProgress` is set. + +## Statements and invocations + +- `await` is removed, including from `await foreach`. +- `ConfigureAwait` is removed from tasks and from async enumerations, including standalone + `ConfigureAwait` statements. +- `WaitAsync` and `WithCancellation` calls are removed. +- Invocations ending in `Async` are rewritten to call the sync overload: + `MoveNextAsync()` becomes `MoveNext()`. +- Async invocations without an `Async` suffix are removed. +- `CancellationToken` arguments are dropped from calls. +- `IProgress.Report(T)` calls are removed unless `PreserveProgress` is set. +- `Memory.Span` property accesses are removed, the value already being a span. +- `await Task.FromResult(value)` becomes `value`. +- `await Task.Delay(value)` becomes `Thread.Sleep(value)`. +- Any invocation returning `ConfiguredCancelableAsyncEnumerable` becomes `GetEnumerator()`. + +## Documentation + +XML documentation comments are carried over. A `` block is dropped when the sync method +returns nothing. `using` directives from the source file are carried into the generated file, so +`cref`s that relied on them still resolve. + +## Where it stops + +The rewriter is syntactic and semantic, not a general translator. Constructs it does not know +about are copied through unchanged, which usually surfaces as a compile error in the generated +file rather than as silently wrong code. Read the generated output before trusting it - see the +verification section of the skill. From d851eb641a6e8e4271afd5b5a5b080bb9ebc7395 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Thu, 27 Aug 2026 01:15:29 -0400 Subject: [PATCH 2/2] Name the marketplace after the company, not the package The marketplace name is independent of the repository it is served from, so naming it after this one package made the install command stutter and left no room for a second plugin. Zomp owns the marketplace; this package is one plugin inside it. claude plugin install sync-method-generator@zomp Generated with Claude Code --- .claude-plugin/marketplace.json | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5657912..20c0459 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { - "name": "sync-method-generator", - "description": "Skills for Zomp.SyncMethodGenerator, the .NET source generator which writes the sync half of a sync/async method pair", + "name": "zomp", + "description": "Zomp's Claude Code plugins. Currently skills for Zomp.SyncMethodGenerator, the .NET source generator which writes the sync half of a sync/async method pair", "owner": { "name": "Zomp" }, diff --git a/README.md b/README.md index 952aa78..8063fad 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ This repository is also a [Claude Code](https://claude.com/claude-code) marketpl ```sh claude plugin marketplace add zompinc/sync-method-generator -claude plugin install sync-method-generator@sync-method-generator +claude plugin install sync-method-generator@zomp ``` The plugin ships a single skill covering setup, the attribute options, the transformation table, and how to verify that a generated method is the hand-written one it replaces.