feat(mapping): v1.5 — DeepClone + CycleSafe full integration (B12)#19
Merged
Conversation
v1.5 ships the missing combined-flags behavior: when both DeepClone=true and CycleSafe=true are set on a [Map<,>], every reachable reference type participates in runtime cycle resolution via generator-emitted per-type helpers, not just types with explicit nested [Map<,>] declarations. Locks: - D-1: full cycle-safe deep clone semantic - D-2: ZAMP021 only on cycles through primary-ctor-only types - D-3: shared per-type __CloneCycleSafe_T helpers on the MapperClass - D-4: single gen-time graph-walk + dedup pass Solo CycleSafe and solo DeepClone paths byte-identical (snapshot-verified post-implementation).
11 tasks task-by-task TDD: declare ZAMP021, build the graph-walk pass, emit per-type helpers, wire MapEmitter + ResolveCycleSafeExpression, add diagnostic tests, runtime tests, allocation budget, docs, and push + admin-merge.
… cycles Reserves the diagnostic descriptor for cycles through primary-ctor-only types under [Map(DeepClone = true, CycleSafe = true)]. The diagnostic itself fires in the new graph-walk pass added by subsequent tasks. Reuses the ZAMP021 slot already pre-reserved by the TODO comment in MapEmitter.cs.
…caffold) Per-MapperClass graph walk that collects reachable reference types from every [Map(DeepClone=true, CycleSafe=true)] declaration. Returns a deduped dictionary keyed by type, with HasParameterlessCtor flag. Cycles through primary-ctor-only types fire ZAMP021 via the diagnostic sink callback. Helper emit + ResolveCycleSafeExpression wiring follow in next tasks.
Adds EmitClonerHelpers + EmitHelper + EmitPropertyAssignment + InlinePrimaryCtorClone + MangleTypeName. One private static helper per parameterless-ctor type in the collected reachable set; primary- ctor-only types get inline new T(...) at their call sites. Not wired into MapEmitter yet — that's the next commit.
MapEmitter.Emit now returns (Source, Diagnostics) and routes ZAMP021 diagnostics through the diagnostic sink. The graph walk runs only when a [Map(DeepClone=true, CycleSafe=true)] declaration is present; helpers are emitted at the top of the partial class for shared use across all declarations. ResolveCycleSafeExpression still has no clone-helper fallback branch — that arrives in the next commit. Existing solo-flag paths are unchanged (verified by green test run). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ResolveCycleSafeExpression gains a new branch (before standard conversion) that routes reference-typed properties without an explicit nested [Map<,>] to __CloneCycleSafe_T(srcExpr, tracker) -- or to inline new T(arg1: ..., arg2: ...) for acyclic primary-ctor-only types. Branch activates only when reachable is not null, which only happens when the originating declaration has both DeepClone and CycleSafe. Solo flag paths are byte-identical (verified by green test run). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CollectReachableTypes was skipping any property type that had a matching [Map<T, T>] declared in the MapperClass — but for the DeepClone+CycleSafe declaration itself, the destination IS that T, so the walk bailed out before ever discovering the back-reference through Node.Next : Node?. ZAMP021 therefore never fired for the canonical recursive-record case. Exclude the originating decl from the auto-skip so the walk still recurses into self-cycles; other nested [Map<,>] entries continue to short-circuit as before.
Two diagnostic tests:
- Positive: a cyclic record type (Node holds Node?) under DeepClone+CycleSafe
fires ZAMP021 with the type name in the message.
- Negative: a non-cyclic record type as a property of a parameterless-ctor
container under DeepClone+CycleSafe does NOT fire ZAMP021.
Four runtime tests on the new emit path:
- Cyclic A->B->A cloned without infinite recursion; tracker dedups.
- Diamond aliasing: src has two refs to one Leaf; clone has two refs
to one cloned Leaf (tracker dedup).
- Collection of clone-only Item with cycles through Buddies.
- Mixed graph: explicit nested [Map<Owner,Owner>] + literal-walked
Wrapper sharing one tracker, with a self-ref cycle on Wrapper.
Each test asserts NotSame on cloned outer + Same on diamond/cycle
preserved through clones.
Covers the combined path under AllocationGate (1000 iterations). Budget set wide initially; tighten via a follow-up after benchmark baselining.
…ycleSafe Documents the v1.5 combined-flags semantic: every reachable reference type participates in cycle resolution via emitted __CloneCycleSafe_T helpers; primary-ctor-only types in cycles surface as ZAMP021. Marks B12 shipped in backlog.md.
…Info.IsCyclic
Post-review cleanup:
- EmitCycleSafeMapPair's XML doc no longer says 'ZAMP021 TODO deferred';
it now points at the shipped diagnostic.
- ReachableTypeInfo no longer carries IsCyclic — the field was always
false because WalkType returns early on cycle detection. Dropping it
keeps the type honest.
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.
Summary
Lands backlog item B12. When
[Map(DeepClone = true, CycleSafe = true)]is declared, every reachable reference type now participates in runtime cycle resolution — not just types with explicit nested[Map<,>]declarations.Why
v1.4 (PR #17) shipped DeepClone and CycleSafe as separate features. Post-merge code review (C-1) discovered that the combined flags silently aliased reference-typed properties that lacked an explicit nested
[Map<,>]. The v1.4 fix was to align docs with the actual behaviour; B12 tracked the proper integration. This PR ships that integration.What changed
src/ZeroAlloc.Mapping.Generator/CycleSafeDeepCloneEmitter.cs— graph-walk pass + helper-emit code (one shared__CloneCycleSafe_T(src, tracker)per parameterless-ctor reachable type).src/ZeroAlloc.Mapping.Generator/MapEmitter.cs—Emitnow returns(string, IReadOnlyList<Diagnostic>);ResolveCycleSafeExpressiongains a new fallback branch routing reference-typed properties without explicit nested[Map<,>]to the per-type clone helpers (or to inlinenew T(...)for acyclic primary-ctor-only types).src/ZeroAlloc.Mapping.Generator/MappingGenerator.cs— consumes the diagnostics list, routes throughSourceProductionContext.ReportDiagnostic.src/ZeroAlloc.Mapping.Generator/Diagnostics.cs— newZAMP021descriptor.tests/ZeroAlloc.Mapping.Tests/CycleSafeDeepCloneTests.cs— four runtime tests (cyclic, diamond, collection-with-cycles, mixed).tests/ZeroAlloc.Mapping.Generator.Tests/DeepCloneDiagnosticTests.cs—ZAMP021positive + negative.tests/ZeroAlloc.Mapping.Tests/AllocationBudgetTests.cs— budget gate for the combined path (4096 B / 1000 iter on a 10-node cyclic ring).docs/cycle-safe-mapping.md,docs/diagnostics.md,docs/deep-clone.md,docs/backlog.md.One in-flight generator bug fix
Commit
0894b73fixes a walk-skip bug discovered during ZAMP021 testing: the original walk skipped any property type that already had a matching[Map<T, T>]— but for[Map<Node, Node>(DeepClone+CycleSafe)], the originating decl IS that match, so the walk bailed before recursing intoNode.Nextand the cycle was never detected. The fix excludes the originating decl from the auto-skip.Backward compatibility
Strictly additive. Solo
CycleSafeand soloDeepClonepaths are byte-identical (gated byreachable is not nullAND(decl.DeepClone && decl.CycleSafe)). The combined path goes from "silently aliases" to "actually clones cyclically" — a semantic change, but in line with the documented intent before the v1.4 C-1 docs alignment and what users would expect from the flag combination.SemVer:
feat:→ minor bump (Mapping v1.4 → v1.5). One new diagnostic (ZAMP021) — fires only when both flags are set AND the graph has a cycle through a primary-ctor-only type.Allocation cost
10-node cyclic ring graph absorbs comfortably within 4096 B / 1000 iter under
AllocationGate. Dominated by 1Dictionary<object,object>per top-level call + N helper-instance allocs + N dict entries.Design + plan
docs/plans/2026-05-23-mapping-v1.5-deepclone-cyclesafe-design.mddocs/plans/2026-05-23-mapping-v1.5-deepclone-cyclesafe.mdTest plan
Out of scope
🤖 Generated with Claude Code