Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

### Fixed

- Issue #29: patch-mode nested updaters consume the captured direct source value once, preserving null skips, overload selection, and sequential update behavior. [#29](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/29)

- Issue #11: existing-target mappings with `NullableMismatch.Throw` now evaluate and enforce nullable preconditions in deterministic per-member order, preserving earlier assignments when a later member fails. [#11](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/11)
- Issue #12: declared cycle tracking now identifies mappings by symbol identity instead of method name, preventing overload false positives, invalid tracker arguments, and unnecessary tracking state. [#12](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/12)
- Issue #13: direct nullable sources passed to nested updaters are evaluated once, preventing duplicate source getter or path evaluation. [#13](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/13)
Expand Down
7 changes: 7 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Mammoth.LiteMapper Decision Log

## Issue #29: reuse direct patch captures without changing overload binding (2026-10-09)

- Context: patch planning creates a single-evaluation capture for direct nullable members, but nested updater invocation used the original getter. Present values were read twice; changing getters could supply a different value or null after the guard.
- Decision: prefer the existing patch capture before the existing Throw capture and direct expression. For direct nullable value types, cast the unwrapped pattern capture back to its original source type, preserving the updater overload that was selected from that type. Configured-path handling and per-member emission order remain unchanged.
- Contract: sections 9.4, 11.2-11.4, 14.4, 16.1, and 16.3 already define selection, single evaluation, null skipping, and sequential failure behavior. No specification semantics or public API change is required.
- Evidence: 34 initial generator cases on unchanged production yielded 17 failures and 17 controls, zero skips; the normally compiled Basic example independently threw NullReferenceException. A nullable-struct overload control passed on unchanged production and failed the naive one-line capture candidate (99 instead of the selected updater's 7), motivating the type-preserving cast. Final tests extend that control to present, changing, null-first, and null-second getters.

## Issue #28: apply the selected matching policy before exact priority (2026-10-09)

- Context: MatchSource selected a sole exact candidate even under IgnoreCase. Constructor matching already limits exact priority to Exact and ExactThenIgnoreCase. Structural helper planning also discarded an ambiguous match without reporting its specific member diagnostic.
Expand Down
8 changes: 8 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Mammoth.LiteMapper Implementation Plan

## Issue #29: captured patch-mode nested updater input (2026-10-09)

- Scope: pass the existing per-member patch capture to direct nested updater calls so the guard and invocation consume the same value exactly once. Preserve configured paths, Throw captures, null skipping, updater selection, and per-member update order.
- Test-first: cover declared and handwritten void/returning updaters, stable/changing/null getters, configured paths, writable/get-only targets, warning-free C# 9 output, and earlier assignments retained after later failure. Add a normally compiled/package-backed consumer example.
- Implementation: use the already allocated direct-member capture at the updater argument boundary; no new abstraction, public API, or specification semantics.
- Validation: focused/full generator, runtime/integration, source samples, available packaging/consumer/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI.
- Boundary: issue #29 only; stop at draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes.

## Issue #28: IgnoreCase member ambiguity (2026-10-09)

- Scope: apply unique ordinal-ignore-case matching without exact-match priority to automatic source members. Preserve Exact and ExactThenIgnoreCase, constructor matching, hidden-member selection, and explicit configuration precedence.
Expand Down
10 changes: 10 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Mammoth.LiteMapper Status

## GitHub issue #29 patch nested updater capture checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop af3fd9ed403da20043705ce8e387578fbe7ad78c after PR #42 merged. Issue #29 was the oldest remaining P2, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes.
- Red-first: 34 initial generator cases yielded 17 failures and 17 controls on unchanged production, zero skips. Present direct getters ran twice; changing getters mapped the wrong child or threw NullReferenceException on a second-read null. The normally compiled Basic example independently threw NullReferenceException. A nullable-struct overload control passed unchanged production, then failed the naive capture candidate (99 instead of 7); retaining the original nullable value type preserves the selected overload.
- Implementation: direct nested updater arguments reuse their existing patch capture, falling back to the existing Throw capture/direct expression. Direct nullable value captures are cast to their original source type for overload binding. Configured paths, per-member emission order, null skipping, updater eligibility, and public API are unchanged. Usage, consumer guidance, and changelog are synchronized.
- Verified core evidence: Release solution build has zero warnings/errors. All 837 generator tests pass, including 38 new cases, plus five runtime and one integration test, zero failures/skips. The combined new/nested/update/path subset passes 77/77. Fixtures compile under C# 9 without warnings and cover void/returning declared and handwritten updaters, get-only/writable children, changing/null getters, instance/nullable-input methods, Throw behavior, deterministic output, and retained earlier assignments/exception identity on later failure. Independent read-only review and consumer-skill/CRLF-aware diff checks pass.
- Verified package evidence: 224 passes, two infrastructure failures, zero skips in the available packaging suite. All 12 framework/language combinations, clean-package/source samples, usage, package/API checks, and benchmark smoke pass. Basic, matrix, and trimmed/AOT consumers include the changing-getter reproduction and null-preservation control. Actual netstandard2.0/C# 9 generated source was inspected: guard and invocation consume the same capture.
- Local limits: trimming and Native AOT fail at ILLink ComputeManagedAssemblies task-host creation with MSB4216 / Unix-domain SocketException 13, before publish behavior is established. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. No warning, assertion, dependency, prerequisite, credential, or security setting was weakened. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR.
- Boundary: issue #29 only, then human review. The previously documented nullable-intermediate enum defect remains outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch.

## GitHub issue #28 IgnoreCase ambiguity checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 14eaa974769554884d7f8ca71ff8821a6e19470f after PR #41 merged. Issue #28 was the oldest remaining P2, with no blocking dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes.
Expand Down
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,8 @@ For a writable non-null child, select the updater explicitly with `[MapProperty(

Omitting `Source` in `[MapProperty(Target = nameof(Target.Child), Use = nameof(ApplyChild))]` passes the root source to `void ApplyChild(Source source, ChildTarget target)`. No source member named `Child` is required: the updater can copy root `Value == 3` into `target.Child.Value`.

The patch guard and nested updater share one captured source value: a nullable getter is evaluated once, even if another read would return a different object or null. The [Basic sample](../samples/Mammoth.LiteMapper.Samples.Basic/Program.cs) demonstrates this with `PatchUpdaterExample`.

Patch mode also applies to nested updater calls. If `source.Child` is null and `IgnoreNullSourceMembers` is enabled, the updater is skipped and the existing target child remains unchanged. With the default mismatch policy, passing a nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` raises `InvalidOperationException` whose message includes the `Child` path.

A destination-returning updater can create a nullable writable child. For example, `ChildTarget ApplyChild(ChildSource source, ChildTarget? target)` may return a new child; the parent mapping assigns that return value. A nullable get-only child cannot store such a replacement and reports `LITEMAPPER5005`.
Expand Down
39 changes: 39 additions & 0 deletions samples/Mammoth.LiteMapper.Samples.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal static class Program
private static void Main()
{
EnumPathExample.Run();
PatchUpdaterExample.Run();
var staticTarget = StaticMapper.Map(new StaticSource
{
Name = "Ada",
Expand Down Expand Up @@ -233,3 +234,41 @@ public sealed class EnumPathSource { public EnumPathChild? Child { get; set; } }
public sealed class EnumPathChild { public InputState State { get; set; } }
public sealed class EnumPathTarget { public OutputState State { get; set; } }
}

namespace Mammoth.LiteMapper.Samples.Basic
{
public static class PatchUpdaterExample
{
public static void Run()
{
var source = new PatchUpdaterSource(new PatchUpdaterChild { Value = 7 });
var target = new PatchUpdaterTarget();
var original = target.Child;
PatchUpdaterMapper.Apply(source, target);
if (source.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child))
throw new System.InvalidOperationException("Patch updater must use its captured child once.");
var missing = new PatchUpdaterSource(null);
PatchUpdaterMapper.Apply(missing, target);
if (missing.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child))
throw new System.InvalidOperationException("A null patch child must preserve the existing destination.");
}
}

[LiteMapper(IgnoreNullSourceMembers = true)]
public static partial class PatchUpdaterMapper
{
public static partial void Apply(PatchUpdaterSource source, PatchUpdaterTarget target);
public static partial void ApplyChild(PatchUpdaterChild source, PatchUpdaterChildTarget target);
}

public sealed class PatchUpdaterSource
{
private readonly PatchUpdaterChild? first;
public PatchUpdaterSource(PatchUpdaterChild? first) { this.first = first; }
public int Reads;
public PatchUpdaterChild? Child { get { Reads++; return Reads == 1 ? first : null; } }
}
public sealed class PatchUpdaterChild { public int Value { get; set; } }
public sealed class PatchUpdaterChildTarget { public int Value { get; set; } }
public sealed class PatchUpdaterTarget { public PatchUpdaterChildTarget Child { get; } = new PatchUpdaterChildTarget(); }
}
2 changes: 1 addition & 1 deletion skills/mammoth-litemapper/references/mapping-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,4 @@ For a writable non-null child, select the updater explicitly with `[MapProperty(

Omitting `Source` in `[MapProperty(Target = nameof(Target.Child), Use = nameof(ApplyChild))]` passes the root source to `void ApplyChild(Source source, ChildTarget target)`. No source member named `Child` is required: the updater can copy root `Value == 3` into `target.Child.Value`.

Nested updater calls obey patch and nullability rules. With `IgnoreNullSourceMembers`, null `source.Child` skips the updater and preserves the destination. Under the default mismatch policy, nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` identifies the member path. A returning updater may create and return a nullable writable child, and the parent assigns it. A nullable get-only child cannot store a returned replacement and reports `LITEMAPPER5005`.
Patch guards and nested updater calls share the same captured source value, so direct nullable getters are read once and null skips the updater. Direct nullable value captures preserve the selected updater overload. Nested updater calls obey patch and nullability rules. With `IgnoreNullSourceMembers`, null `source.Child` skips the updater and preserves the destination. Under the default mismatch policy, nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` identifies the member path. A returning updater may create and return a nullable writable child, and the parent assigns it. A nullable get-only child cannot store a returned replacement and reports `LITEMAPPER5005`.
7 changes: 6 additions & 1 deletion src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,12 @@ private static MappingModel CreateUpdateMappingModel(IMethodSymbol method, Compi
? escapedSourceName
: selected != null
? sourcePathCaptureName ?? throwCaptureName ?? BuildNullSafeSourcePathExpression(source.Name, selected)
: throwCaptureName ?? escapedSourceName + "." + EscapeIdentifier(match.Member.Name);
: sourcePathCaptureName ?? throwCaptureName ?? escapedSourceName + "." + EscapeIdentifier(match.Member.Name);
if (selected == null && sourcePathCaptureName != null && nestedSourceType.IsValueType)
{
// A pattern capture unwraps Nullable<T>; retain the selected input type for overload binding.
argument = "(" + DisplayType(nestedSourceType) + ")" + argument;
}
string? updaterGuard = null;
PreconditionModel? updaterPrecondition = null;
if (sourceMayBeNull)
Expand Down
Loading
Loading