Repository navigation
(MAJOR): Make Reflection.Emit the only runtime patch class builder - #142
Merged
Merged
Conversation
This was referenced Oct 4, 2026
PaulTrampert
added a commit
that referenced
this pull request
Oct 4, 2026
…flow (#146) Closes #145 ## Cause Breaking changes for 2.0 are staged on `release/2.0`, but `dotnet-library.yml` only triggered `pull_request` runs for `main`, so #139–#142 had no CI build. ## Fix - Add `release/**` to the `pull_request` branches. Publishing still runs only for `main`: the `push` trigger is unchanged, and the shared workflow publishes only when `github.ref` is `main`. - Document the release-branch flow in `AGENTS.md`. PRs into `release/<major>` are squash-merged. The release PR into `main` is rebase-merged, because release notes are now built from commit subjects (PaulTrampert/github-workflows#6), and a squash would collapse them to one line. A `pull_request` run uses the workflow file from the base branch, so `release/2.0` needs this commit too. It has nothing `main` lacks, so it can be fast-forwarded to `main` once this merges. ## Rejected alternatives - Shipping releases by pushing `release/2.0:main` directly, with a merge-gate check and an admin bypass. That kept commit SHAs for GitHub's generated release notes, which notes built from commit subjects no longer need. ## Outside this PR Already applied through the API: rebase merging enabled for the repository, `rebase` allowed in both `main` rulesets, and a new `release/**` ruleset (squash only, build and PR-title checks required). ## Tests No code changes. YAML change verified by inspection; this PR's own build exercises the `main` trigger. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
PatchClassBuilder now always delegates to EmitPatchClassBuilder. The
CodeDom/Roslyn builder, its Microsoft.CodeAnalysis.CSharp and
System.CodeDom references, and PatchClassBuilder.UseExperimentalDynamicClassBuilder
are removed. Internal write models are supported when their assembly grants
InternalsVisibleTo("PTrampert.SimplePatch.Emitted"), and single-file
publishing now works.
Closes #126
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maintainer wants RoslynPatchClassBuilder kept until #144 decides whether it becomes a source generator. It is restored, unused at runtime, with its package references and its tests, which call the internal builders directly. PatchClassBuilder still delegates only to the Emit builder, and UseExperimentalDynamicClassBuilder stays removed. The public entry point tests move to PatchClassBuilderDelegationTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PaulTrampert
force-pushed
the
feature/126-remove-roslyn-builder
branch
from
October 4, 2026 21:30
05d90e5 to
ec335f3
Compare
# Conflicts: # AGENTS.md # PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs # PTrampert.SimplePatch/PatchClassBuilder.cs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PaulTrampert
added a commit
that referenced
this pull request
Oct 4, 2026
…make PatchClassBuilder static (#141) ## Cause #133 made `PatchClassBuilder` a facade over the internal `RoslynPatchClassBuilder`, and #95 added `UseExperimentalDynamicClassBuilder`, which the facade's `GetPatchClassFor` read on every call. Both kept `PatchClassBuilder.Instance` typed as `PatchClassBuilder`, because changing a property's type breaks binary compatibility. This is the 2.0 change that #135 deferred. Once `Instance` hands out the selected internal builder, nothing hands out a `PatchClassBuilder` any more. Its instance surface (the obsolete constructor, instance `GetPatchClassFor`, and its `IPatchClassBuilder` implementation) only forwards to `Instance` and is dead weight (#143). The two changes are one PR because a static class can't be a property type, so #143 can't compile without #135. ## Fix **Retype `Instance` (#135)** - `public static IPatchClassBuilder Instance` returns the selected builder itself: `EmitPatchClassBuilder.Instance` when the flag is on, otherwise `RoslynPatchClassBuilder.Instance`. Both internal builders already had static `Instance` singletons. - `Instance` is a computed property (`=>`), not an initialised one, so it honours the flag at read time. The flag is a settable static, and the tests flip it back and forth. - Call sites (`PatchJsonConverterFactory`, the Swashbuckle filter, the OpenAPI transformer) already call `PatchClassBuilder.Instance.GetPatchClassFor(...)` on each use, so they compile and behave unchanged against the new type. None of them caches the builder. **Make `PatchClassBuilder` static (#143)** - `public static class PatchClassBuilder` now holds only `Instance` and `UseExperimentalDynamicClassBuilder`. - The constructor, instance `GetPatchClassFor` and the `IPatchClassBuilder` implementation are removed. - The XML docs worth keeping moved. The description of the generated class (its properties, its `Patch` method, sealed and public, the `.Optionals` namespace) is now in the class remarks. The `NotSupportedException` conditions are now in `Instance`'s remarks. `IPatchClassBuilder.GetPatchClassFor` keeps its builder-neutral contract. `RoslynPatchClassBuilder.GetPatchClassFor` had `<inheritdoc cref="PatchClassBuilder.GetPatchClassFor"/>` and now has its own summary and exception docs. - `RoslynPatchClassBuilderTest.GetPatchClassFor_SharesGeneratedTypesAcrossBuilders` constructed `PatchClassBuilder` twice. It is now `GetPatchClassFor_CachesTheGeneratedType`, which calls `RoslynPatchClassBuilder.Instance` twice and still checks that `PatchClassBuilder.Instance` agrees with it while the flag is off. The internal builders have private constructors, so a second instance can't be constructed. **Docs:** `README.md` and `docs/getting-started.md` note that `Instance` returns the selected builder, so you should read it where you use it and not keep it. `AGENTS.md` describes the static class. **Breaking:** - Binaries compiled against `PatchClassBuilder PatchClassBuilder.Instance { get; }` fail with `MissingMethodException`. - `new PatchClassBuilder()` (obsolete since 1.x) and instance `GetPatchClassFor` are gone. - `PatchClassBuilder` no longer implements `IPatchClassBuilder`, and can't be used as a variable, parameter or generic argument type. ## Coordination with sibling PRs into `release/2.0` - **#139 (#75) makes the constructor internal.** This PR deletes the constructor, which supersedes that change. When the two meet, resolve the conflict by deleting the constructor. - **#142 (#126)** makes Emit the only runtime builder and removes the flag. Once it lands, `Instance` collapses to `EmitPatchClassBuilder.Instance`. - **#140 (#76)** changes TFMs. They are unchanged here. ## Alternatives rejected - **Initialise `Instance` once (`{ get; } = ...`).** This would freeze whatever builder the flag selected at type initialisation and ignore later changes to the flag. - **Keep returning the facade, typed as the interface.** This would avoid the read-time caveat, but it keeps the per-call forwarding that #135 asks to remove. It also leaves `Instance` as the one thing that hands out a `PatchClassBuilder`. - **Keep `PatchClassBuilder` non-static, with the constructor internal (#75 alone).** Nothing would construct it, so its instance members would be unreachable dead code. - **Ship #143 as a separate PR stacked on this one.** The maintainer chose to fold it in, because #143 can't compile without the retype. ## Tests New tests in `UseExperimentalDynamicClassBuilderTest`: - `Instance_IsTheRoslynBuilderWhenOff` - `Instance_IsTheEmitBuilderWhenOn` - `Instance_FollowsTheFlagWhenItIsTurnedBackOff` Against the old `PatchClassBuilder.cs`, the NUnit analyzer rejects all three with NUnit2020 (a `SameAs` that always fails because the types are mutually exclusive), so they fail before the fix. CI's build pipeline doesn't run on PRs that target a branch other than `main`, so these results are local only: - `dotnet build`: 0 errors, 21 warnings, the same count as the base commit. - `dotnet test`: all passed. Core 132/132, Swashbuckle 18/18, OpenApi 12/12. The net8.0 test hosts ran with `DOTNET_ROLL_FORWARD=Major`, because only the .NET 10 runtime is installed locally. Closes #135 Closes #143 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # AGENTS.md # PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs # PTrampert.SimplePatch.Test/UseExperimentalDynamicClassBuilderTest.cs # PTrampert.SimplePatch/PatchClassBuilder.cs # PTrampert.SimplePatch/RoslynPatchClassBuilder.cs # README.md # docs/getting-started.md
✅ PR Title Formatted CorrectlyThe title of this PR has been updated to match the correct format. Thank you! |
Merged
PaulTrampert
added a commit
that referenced
this pull request
Oct 4, 2026
Ships the 2.0 major release from `release/2.0`. > [!IMPORTANT] > Merge with **Rebase and merge**, never squash. Release notes are built from the commit subjects since `v1.5.0`, so a rebase merge gives each PR below its own line. A squash would collapse them into one. ## Included - #139 Make `PatchClassBuilder`'s constructor internal (closes #75) - #140 Target net10.0 only (closes #76) - #141 Retype `PatchClassBuilder.Instance` to `IPatchClassBuilder` and make `PatchClassBuilder` static (closes #135, closes #143) - #142 Make Reflection.Emit the only runtime patch class builder (closes #126) ## Breaking changes - **.NET 10 or later only.** .NET 8 and 9 consumers stay on 1.x. - **`PatchClassBuilder` is a static class.** `PatchClassBuilder.Instance` is typed `IPatchClassBuilder`, and the constructor is gone. - **`UseExperimentalDynamicClassBuilder` is removed.** Reflection.Emit is the only runtime builder. Internal write models are supported when their assembly declares `[InternalsVisibleTo("PTrampert.SimplePatch.Emitted")]`. The Roslyn builder is kept but unused at runtime, pending #144. ## Branch state `release/2.0` has no commits behind `main` and no merge commits. All four commits are `(MAJOR)`, so the version calculation produces 2.0.0. Postponed until after 2.0: #96 and #102, with open questions noted on each. ## Tests Each included PR passed the CI build and tests against `release/2.0`. This PR's build runs the full suite on the combined branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #126
Cause
Since #130 and #136 the library has had two builders for the same patch class. One is the original builder, which generates C# with CodeDom and compiles it with Roslyn. The other is a Reflection.Emit builder, behind the experimental
PatchClassBuilder.UseExperimentalDynamicClassBuilderflag that shipped in v1.5.0. The process-wide switch adds runtime complexity, and the Roslyn path has limits that the Emit path doesn't have. It can't patch internal write models, and it breaks under single-file publishing because it builds metadata references fromAssembly.Location.Fix
PatchClassBuilder.GetPatchClassFornow always delegates toEmitPatchClassBuilder.Instance. Emit is the only builder used at runtime.PatchClassBuilder.UseExperimentalDynamicClassBuilderis removed outright, and so are its tests. ItsGetPatchClassFor<exception>docs now describe when Emit throws.RoslynPatchClassBuilderis kept as internal, unused, tested code, pending Generate patch classes at compile time with a source generator #144. That issue decides whether it becomes a compile-time source generator. This departs from Remove the Roslyn-based patch class builder in favour of Reflection.Emit #126's scope, which asked for the Roslyn path to be deleted, because the maintainer wants it kept until Generate patch classes at compile time with a source generator #144 is decided.Microsoft.CodeAnalysis.CSharpandSystem.CodeDompackage references remain, so consumers still get them transitively. The.csprojhas a comment explaining why.PatchClassBuilderTestis still parameterized over both builders throughPatchClassBuilders, calling the internal builders directly, so the two can't drift apart.RoslynPatchClassBuilderTestis kept. Its shared-cache test, which asserted thatPatchClassBuilderhands out Roslyn types, is now a plain Roslyn cache test.PatchClassBuilderDelegationTestcovers the public entry point:PatchClassBuilder(including the obsolete constructor) hands out the Emit builder's types.IPatchObject<T>with validation.NotSupportedException.EmitPatchClassBuilderTestgains a concurrent-first-use test.ExperimentalDynamicClassBuilderTestfiles are renamed toInternalWriteModelTest, without the flag,TearDownor[NonParallelizable].docs/getting-started.mdmatches the README.AGENTS.md"How it works" describes Emit as the runtime builder, and the Roslyn builder as unused, tested code pending Generate patch classes at compile time with a source generator #144.Single-file publishing (investigated, as #126 asks)
I published
PTrampert.SimplePatch.Samplewith-r linux-x64 --self-contained false -p:PublishSingleFile=trueand sent a PATCH to/People/1:release/2.0(Roslyn): HTTP 500.CS0518: Predefined type 'System.Object' is not defined or importedandCS0234forIPatchObject<>. Roslyn gets no metadata references because the bundled assemblies have noLocation.The README's new Deployment section says single-file publishing is supported, and that Native AOT and trimming aren't. I didn't add an automated single-file test or sample. It would need a publish-and-run step in CI, which is a larger change than this PR should carry.
Observable behaviour changes for consumers
PatchClassBuilder.UseExperimentalDynamicClassBuilderis gone. Code that sets it no longer compiles, and existing binaries fail withMissingMethodException.<Namespace>.Optionals.<Type>_Optionals, without the random suffix.PTrampert.SimplePatch.Emitted, soAssembly.IsDynamicis true and there is noLocation.[InternalsVisibleTo("PTrampert.SimplePatch.Emitted")]. Without that grant, they still throwNotSupportedException, and the message names the grant and the assembly that needs it.NotSupportedException.TypeLoadException/InvalidProgramException), not as Roslyn diagnostics.Microsoft.CodeAnalysis.*andSystem.CodeDomstill arrive transitively, until Generate patch classes at compile time with a source generator #144 is decided.Alternatives rejected
UseExperimentalDynamicClassBuilderas an[Obsolete]no-op. Remove the Roslyn-based patch class builder in favour of Reflection.Emit #126 rules this out. The removal ships in the same major as the other breaking changes, and a compile error is the clearest signal.Coordination with parallel 2.0 PRs
PatchClassBuilder.InstancetoIPatchClassBuilder): this PR leavesInstanceand its type alone. When both land, Retype PatchClassBuilder.Instance to IPatchClassBuilder #135'sInstancegetter will need to resolve toEmitPatchClassBuilder.Instance.PatchClassBuilderDelegationTestcalls it under#pragma warning disable CS0618, and will keep compiling if the constructor becomes internal, because the test project hasInternalsVisibleTo.Test results
dotnet build: 0 errors, 21 warnings, the same count asrelease/2.0.dotnet test:PTrampert.SimplePatch.Test(net8.0): 128 passed. That is 129 onrelease/2.0, minus 5 flag tests, plus 4 new ones.PTrampert.SimplePatch.Swashbuckle.Test(net8.0): 18 passed.PTrampert.SimplePatch.OpenApi.Test(net10.0): 12 passed.DOTNET_ROLL_FORWARD=Major.main, so PRs intorelease/2.0get only the title check. The net8.0 suites will first run in CI whenrelease/2.0is merged tomain.🤖 Generated with Claude Code