Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## Fixes

- Issue #48: configured enum paths preserve missing reference intermediates as null for nullable enum destinations, including constructor-bound and existing-target mappings. By-name errors, by-value conversion, single evaluation, patch skipping, and converter precedence are preserved. [#48](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/48)
- Issue #32: recursive cycle paths retain compact immutable segments and format the full path only when a cycle is detected, removing quadratic path-prefix allocations while preserving exception metadata and first-edge allocation behavior. [#32](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/32)
- Issue #31: dictionary mappings preserve compatible key comparers independently of value conversion. Keys transformed by custom converters use the destination default comparer, even when key types match. [#31](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/31)
- Issue #30: set mappings probe exact accessible HashSet capacity constructors and use portable fallbacks when absent, including on netstandard2.0. Capacity-plus-comparer support is detected independently. [#30](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/30)
Expand Down
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Mammoth.LiteMapper Decision Log

## Issue #48: effective nullability at the enum boundary (2026-10-09)

- Context: a configured source path can evaluate to null even when its terminal enum symbol is non-nullable. Enum lowering previously considered only Nullable<T> symbol wrappers, so a missing reference intermediate entered by-name unknown-value handling or a by-value unwrapping cast.
- Decision: carry effective expression nullability into the selected built-in enum stage, separately from symbol unwrapping, and reuse its existing single-evaluation nullable wrapper. Recursive underlying enum lowering clears the path flag. Earlier converter/language resolution and existing mismatch/patch guards retain their ordering.
- Required patch construction: mark successful built-in enum conversions so a required nullable enum initializer can re-lower the original nullable source path instead of substituting it into an unwrapped patch expression. This does not reselect converters or change the normal existing-target assignment. A nullable-input converter control continues to receive null.
- Contract: sections 9.4, 11.1/11.4, 12.4, 13, 14.3, and 16.1/16.3 already require these semantics. No public API, diagnostic contract, or specification change is introduced. Nullable<T> intermediate traversal resolution is separate; this repair covers supported nullable-reference paths, including subsequent value/tuple fields.
- Evidence: the completed initial 70-case enum fixture has 28 failures and 42 controls on unchanged production. Four additional required-initializer cases fail the first candidate while the explicit converter control passes. All 184 related focused cases pass after the scoped repair; full evidence belongs in STATUS and the PR.

## Issue #47: configured nullable updater captures (2026-10-09)

- Non-semantic repair under specification sections 9.4, 11.4, 14.4, 16.1 and 16.3. A patch pattern capture unwraps Nullable<T>; both automatic and configured source paths must restore the selected source type at invocation so C# overload binding matches generator resolution.
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 #48: nullable enum source-path intermediates (2026-10-09)

- Scope: preserve missing configured source-path intermediates as null for nullable enum targets. Retain known/unknown enum behavior, stable exception metadata, single evaluation, patch skipping, update order, and converter/null-mismatch precedence. No public API or specification semantics change.
- Test-first: exercise creation, constructor binding, updates, reference/value/tuple intermediates, by-name/by-value strategies, nullable-leaf/scalar controls, changing getters, and C# 9 deterministic warning-free output. Add a shared normally compiled and package-consumer example.
- Implementation: carry effective path nullability into enum lowering only after higher-precedence conversions and existing mismatch guards. Reuse the existing nullable enum capture and wrapper.
- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language consumers, available packaging checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. Disclose unavailable local stages.
- Boundary: issue #48 only, then draft PR/human review. No merge, release, tag, package publication, workflow dispatch, or user-computer access. AgentStack CLI is absent; authorized connector fallback added/read back readiness labels without issue comments or claim writes. Dependencies, sub-issues and competing PRs are absent.

## Issue #47: configured nullable updater overload binding (2026-10-09)

- Scope: preserve the selected nullable value-type updater overload after capturing a configured direct or dotted patch source path. Retain single evaluation, null skipping, target identity, and sequential update behavior. No public API or specification semantics change.
Expand Down
11 changes: 11 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Mammoth.LiteMapper Status

## GitHub issue #48 nullable enum source-path checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 6e70b78a742f4fdabfd60472917ae0290bac8dbe after PR #49 merged. Issue #48 has no blocked-by dependencies, sub-issues or competing PRs. Authorized exec:agent and ready:agent labels were added/read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes.
- Red-first: the final 70-case enum fixture yielded 28 failures and 42 passing controls on unchanged production, zero skips. The normally compiled Basic example independently failed for a missing Child. Four required nullable enum initializer cases then failed the first candidate while the explicit nullable-input converter control passed.
- Implementation: pass effective path nullability into built-in enum lowering separately from Nullable<T> symbol unwrapping, reusing its single-evaluation wrapper. Required nullable enum patch construction re-lowers the original nullable path only after selection of the built-in enum stage. Converter precedence, mismatch guards, existing-target patch behavior, update order, public API and specification semantics are unchanged.
- Core evidence: final Release solution build has zero warnings/errors. All 989 generator, five runtime and one integration tests pass with zero failures/skips. The related focused group passes 184/184; all three source samples pass. Independent read-only review, consumer-skill validation, solution-path consistency and CRLF-aware diff checks pass.
- CI fixture recovery: first-head Linux Roslyn jobs passed all 989 cases, but each Windows host exposed five converter controls whose member insertion matched LF-only text. The fixture now inserts at its unique MapProperty attribute instead of matching a newline sequence. All 75 enum cases pass when the test file is compiled with CRLF, preserving every assertion and restoring the original LF line-ending format afterward. Production code is unchanged by this portability correction.
- Package evidence: available packaging suite records 224 passes, two infrastructure failures, zero skips. All 12 framework/language consumers, clean-package samples, usage validation, package/API checks and benchmark smoke pass. The shared NullableEnumPathExample executes in source, clean-package and matrix consumers, including genuine netstandard2.0/C#9 references. Its actual generated package source was inspected for single-evaluation nullable enum wrappers and stable path metadata. Normal/trim/AOT consumers include the same example.
- Local limits: trimming and Native AOT fail before publish behavior is established at ILLink ComputeManagedAssemblies task-host creation, with MSB4216 / Unix-domain SocketException 13. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. No warning, assertion, dependency, prerequisite, credential or security setting was weakened. Official SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12 use workspace-local caches/temporary files and one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR.
- Coverage and boundary: create/constructor/update/patch, by-name/by-value, nullable-leaf/scalar controls, multiple nullable references, subsequent value/tuple fields, changing getters, flags, checked/unchecked conversion, stable unknown-value metadata, converter precedence, deterministic C#9 output and C#11 required initialization. Nullable value-type intermediate traversal that fails earlier source-path resolution is separate and unchanged. Issue #48 only, then human review; no user-computer access, merge, release, tag, package publication or workflow dispatch.

## GitHub issue #47 configured nullable updater checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 9c48bfd59fe586097ca18c4289c85f03e93e30bc after PR #46 merged. New P2 issue #47 has no blocked-by dependencies, sub-issues or competing PRs. Authorized exec:agent and ready:agent labels were added/read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes.
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ public static partial class EnumPathMapper

For this non-null enum target, `Ready = 1` maps by name to `Ready = 10`, a missing `Child` follows the Throw policy, and an unknown value throws `ArgumentOutOfRangeException` with `ParamName` equal to `Child.State` and the original enum in `ActualValue`. Root mappings retain their declared parameter name; direct members retain their source-member name. Exception metadata does not cause another getter evaluation.

Known limitation: a nullable intermediate with a non-nullable enum leaf mapped to a nullable enum target still throws when the intermediate is null. Null preservation for this specific configured-path shape is not yet supported correctly.
For a nullable enum target, a missing reference intermediate preserves null even when the enum leaf is non-nullable. The Basic sample's `NullableEnumPathExample.cs` demonstrates creation, constructor binding, existing-target updates, and patch skipping for `Child.State`. By-name mapping converts `Ready = 1` to `Ready = 10` and retains the exception metadata above for unknown non-null values; by-value mapping preserves null and converts the underlying value according to the enum numeric policy. Explicit converters still take precedence and a nullable-input converter receives a missing path as null.

## Recursive mappings and cycle detection

Expand Down
120 changes: 120 additions & 0 deletions samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System;

namespace Mammoth.LiteMapper.Samples.Basic
{
public static class NullableEnumPathExample
{
public static void Run()
{
foreach (var map in new Func<NullableEnumPathSource, NullableEnumOutputState?>[]
{
source => NullableEnumPathMapper.Create(source).State,
source => NullableEnumPathMapper.Construct(source).State,
Update,
})
{
Check(map, null, null);
Check(map, NullableEnumInputState.Ready, NullableEnumOutputState.Ready);
Check(map, (NullableEnumInputState)99, null, throws: true);
}

Check(source => NullableEnumPathMapper.ByValue(source).State, null, null);
Check(source => NullableEnumPathMapper.ByValue(source).State,
NullableEnumInputState.Ready, (NullableEnumOutputState)1);
Check(source => NullableEnumPathMapper.ByValue(source).State,
(NullableEnumInputState)99, (NullableEnumOutputState)99);

var target = new NullableEnumPathTarget { State = (NullableEnumOutputState)77 };
Func<NullableEnumPathSource, NullableEnumOutputState?> patch = source =>
{
NullableEnumPathMapper.Patch(source, target);
return target.State;
};
Check(patch, null, (NullableEnumOutputState)77);
Check(patch, NullableEnumInputState.Ready, NullableEnumOutputState.Ready);
Check(patch, (NullableEnumInputState)99, null, throws: true);
if (target.State != NullableEnumOutputState.Ready)
throw new InvalidOperationException("A failed enum conversion must not replace the patched value.");
}

private static NullableEnumOutputState? Update(NullableEnumPathSource source)
{
var target = new NullableEnumPathTarget { State = (NullableEnumOutputState)77 };
if (!ReferenceEquals(target, NullableEnumPathMapper.Apply(source, target)))
throw new InvalidOperationException("An enum update must preserve the destination reference.");
return target.State;
}

private static void Check(
Func<NullableEnumPathSource, NullableEnumOutputState?> map,
NullableEnumInputState? state,
NullableEnumOutputState? expected,
bool throws = false)
{
var child = state.HasValue ? new NullableEnumPathChild(state.Value) : null;
var source = new NullableEnumPathSource(child);
try
{
var actual = map(source);
if (throws || actual != expected)
throw new InvalidOperationException("Nullable enum paths must preserve null and the selected enum strategy.");
}
catch (ArgumentOutOfRangeException error) when (
throws && error.ParamName == "Child.State" &&
error.ActualValue is NullableEnumInputState value && value == (NullableEnumInputState)99)
{
}

if (source.Reads != 1 || (child != null && child.Reads != 1))
throw new InvalidOperationException("Each enum source-path getter must be evaluated once.");
}
}

[LiteMapper]
public static partial class NullableEnumPathMapper
{
[MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))]
public static partial NullableEnumPathTarget Create(NullableEnumPathSource source);

[MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathConstructorTarget.State))]
public static partial NullableEnumPathConstructorTarget Construct(NullableEnumPathSource source);

[MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))]
public static partial NullableEnumPathTarget Apply(NullableEnumPathSource source, NullableEnumPathTarget target);

[MappingOptions(EnumMapping = EnumMappingStrategy.ByValue)]
[MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))]
public static partial NullableEnumPathTarget ByValue(NullableEnumPathSource source);

[MappingOptions(IgnoreNullSourceMembers = OptionState.Enabled)]
[MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))]
public static partial void Patch(NullableEnumPathSource source, NullableEnumPathTarget target);
}

public enum NullableEnumInputState { Ready = 1 }
public enum NullableEnumOutputState { Ready = 10 }

public sealed class NullableEnumPathSource
{
private readonly NullableEnumPathChild? child;
public NullableEnumPathSource(NullableEnumPathChild? child) { this.child = child; }
public int Reads { get; private set; }
public NullableEnumPathChild? Child { get { Reads++; return Reads == 1 ? child : null; } }
}

public sealed class NullableEnumPathChild
{
private readonly NullableEnumInputState state;
public NullableEnumPathChild(NullableEnumInputState state) { this.state = state; }
public int Reads { get; private set; }
public NullableEnumInputState State { get { Reads++; return Reads == 1 ? state : (NullableEnumInputState)98; } }
}

public sealed class NullableEnumPathTarget { public NullableEnumOutputState? State { get; set; } }

public sealed class NullableEnumPathConstructorTarget
{
public NullableEnumPathConstructorTarget(NullableEnumOutputState? state) { State = state; }
public NullableEnumOutputState? State { get; }
}
}
1 change: 1 addition & 0 deletions samples/Mammoth.LiteMapper.Samples.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ private static void Main()
{
LazyCyclePathExample.Run();
ConfiguredUpdaterOverloadExample.Run();
NullableEnumPathExample.Run();
EnumPathExample.Run();
PatchUpdaterExample.Run();
var staticTarget = StaticMapper.Map(new StaticSource
Expand Down
2 changes: 2 additions & 0 deletions skills/mammoth-litemapper/references/mapping-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ An unknown-count enumerable-to-array mapping may allocate a temporary growing bu

Enum defaults are `EnumMappingStrategy.ByName`, `UnmatchedEnumValuePolicy.Error`, and `EnumNumericConversion.Checked`. Names match exactly. `Throw` permits unmatched names with runtime `ArgumentOutOfRangeException`; by-value conversion uses the independent enum numeric policy. Domain-specific enum semantics belong in a converter.

In development revisions containing the nullable enum-path repair, a missing reference intermediate in `Child.State` maps to null when the destination enum is nullable, even when `State` is non-nullable. By-name unknown values still throw with the configured path and original value; by-value preserves null before numeric conversion. A nullable-input explicit converter still receives null. Verify the installed revision before relying on this shape; the published 3.0.0 generator may fail to compile the guarded path.

`ReferenceHandling.ThrowOnCycle` detects active-path reference cycles in recursive generated mappings and throws `LiteMapperCycleException`. Shared references on separate branches are not cycles and identity is not preserved. Without tracking, recursive runtime cycles can overflow the stack. The exception exposes `SourceType`, `DestinationType`, `MappingMethod`, and `MemberPath`.

Recursive declared mappings share one tracker. For an `A -> B -> A` component implemented by generated `MapA` and `MapB`, the cycle reports `MapA` at `B.A`. Value helpers forward tracker state but are not entered or boxed; a finite class-to-struct-to-class path maps normally.
Expand Down
Loading
Loading