Skip to content

feat(mapping): v1.5 — DeepClone + CycleSafe full integration (B12)#19

Merged
MarcelRoozekrans merged 14 commits into
mainfrom
feat/mapping-v1.5-deepclone-cyclesafe
May 24, 2026
Merged

feat(mapping): v1.5 — DeepClone + CycleSafe full integration (B12)#19
MarcelRoozekrans merged 14 commits into
mainfrom
feat/mapping-v1.5-deepclone-cyclesafe

Conversation

@MarcelRoozekrans

Copy link
Copy Markdown
Contributor

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

  • NEW src/ZeroAlloc.Mapping.Generator/CycleSafeDeepCloneEmitter.cs — graph-walk pass + helper-emit code (one shared __CloneCycleSafe_T(src, tracker) per parameterless-ctor reachable type).
  • MOD src/ZeroAlloc.Mapping.Generator/MapEmitter.csEmit now returns (string, IReadOnlyList<Diagnostic>); ResolveCycleSafeExpression gains a new fallback branch routing reference-typed properties without explicit nested [Map<,>] to the per-type clone helpers (or to inline new T(...) for acyclic primary-ctor-only types).
  • MOD src/ZeroAlloc.Mapping.Generator/MappingGenerator.cs — consumes the diagnostics list, routes through SourceProductionContext.ReportDiagnostic.
  • MOD src/ZeroAlloc.Mapping.Generator/Diagnostics.cs — new ZAMP021 descriptor.
  • NEW tests/ZeroAlloc.Mapping.Tests/CycleSafeDeepCloneTests.cs — four runtime tests (cyclic, diamond, collection-with-cycles, mixed).
  • MOD tests/ZeroAlloc.Mapping.Generator.Tests/DeepCloneDiagnosticTests.csZAMP021 positive + negative.
  • MOD tests/ZeroAlloc.Mapping.Tests/AllocationBudgetTests.cs — budget gate for the combined path (4096 B / 1000 iter on a 10-node cyclic ring).
  • MOD docs/cycle-safe-mapping.md, docs/diagnostics.md, docs/deep-clone.md, docs/backlog.md.

One in-flight generator bug fix

Commit 0894b73 fixes 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 into Node.Next and the cycle was never detected. The fix excludes the originating decl from the auto-skip.

Backward compatibility

Strictly additive. Solo CycleSafe and solo DeepClone paths are byte-identical (gated by reachable is not null AND (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 1 Dictionary<object,object> per top-level call + N helper-instance allocs + N dict entries.

Design + plan

  • Design: docs/plans/2026-05-23-mapping-v1.5-deepclone-cyclesafe-design.md
  • Plan: docs/plans/2026-05-23-mapping-v1.5-deepclone-cyclesafe.md

Test plan

  • All existing tests remain green (121 pre-existing, solo paths unchanged).
  • 4 new runtime tests cover cyclic, diamond, collection-with-cycles, and mixed-graph cases.
  • 2 new diagnostic tests cover ZAMP021 positive + negative.
  • 1 new allocation-budget test covers the combined path under `AllocationGate`.
  • 130/130 tests pass locally (47 in Mapping.Tests + 83 in Mapping.Generator.Tests).
  • CI green: build + tests + aot-smoke.

Out of scope

  • `[CycleSafeIgnore]` property attribute — YAGNI.
  • Tracker pooling — single-tracker-per-call is fine for now.
  • Typed tracker — `ReferenceEqualityComparer.Instance` works across mixed types.

🤖 Generated with Claude Code

MarcelRoozekrans and others added 14 commits May 24, 2026 14:47
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.
@MarcelRoozekrans
MarcelRoozekrans merged commit 8397193 into main May 24, 2026
3 checks passed
@MarcelRoozekrans
MarcelRoozekrans deleted the feat/mapping-v1.5-deepclone-cyclesafe branch May 24, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant