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
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 #27: by-name enum exceptions use stable source names and paths instead of applying `nameof` to guarded expressions; nullable enum captures retain source metadata. [#27](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/27)
- Issue #26: structural collection mapping preserves nullable elements and applies Throw guards before object helpers; nullable nested collection helper signatures retain their annotations. [#26](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/26)
- Issue #25: cycle detection now forwards one context through acyclic object and collection bridges, retaining complete member paths without tracking the bridge objects. [#25](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/25)
- Issue #24: partial mappers with framework attributes or `UseMapper` on another part now generate once; genuine duplicate `LiteMapper` configuration still reports one fatal diagnostic. [#24](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/24)
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 #27: separate enum exception metadata from executable expressions (2026-10-09)

- Context: configured nullable traversal renders coalescing/null-conditional expressions that are invalid nameof operands. Nullable enum conversion replaces source expressions with generated captures, leaking private names into exception metadata.
- Decision: carry the selected source parameter, direct member, configured path, or collection element/key/value name separately through enum planning and nullable recursion. Render it with Roslyn's escaped string literal API. Retain the existing switch-arm value capture, preserving ActualValue and exactly-once getters. The existing root snapshot changes only nameof(source) to the equivalent literal.
- Contract: sections 9.4, 12.4-12.5, 13.1, 13.4, 16.1, 19.5, and 22.11/22.15 already govern these paths; no public API, enum strategy, null policy, converter precedence, or specification change is introduced.
- Boundary: a separate pre-existing nullable-intermediate/non-null enum leaf/nullable-target case still lacks runtime null preservation. The original runtime reproduction is retained in task evidence; this change only verifies its compilation and source-name fix. The limitation is disclosed in usage/status/PR evidence, without claiming that runtime case passes.

## Issue #26: enforce nullability at the structural element boundary (2026-10-09)

- Context: language conversion creates a nullable Throw guard before probing conversion, but discards it when structural mapping is required. The element fallback then passed null directly into a deliberately non-null object helper. Nullable target elements also had no preserving branch.
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 #27: stable enum exception names (2026-10-09)

- Scope: separate by-name enum exception metadata from rendered value expressions. Preserve root parameter/direct source-member names; configured source paths remain identifiable even after null guards or captures.
- Test-first: compile and execute configured nullable intermediates/leaves for known, unknown, and null values, including creation, constructor binding, updates, patch behavior, flags, and C# 9. Retain Error-policy and root/direct-member controls, ActualValue, single getter evaluation, and determinism checks.
- Implementation: pass stable source names through nullable enum recursion without re-evaluating values or changing conversion precedence, null policy, exception types, or enum strategy.
- Validation: focused/full generator, runtime/integration, samples, clean-package consumers and framework/language matrix, available packaging checks, independent review, and exact-head ordinary PR CI.
- Boundary: issue #27 only; no specification or public API change, other issue, merge, release, tag, package publication, or workflow dispatch. Stop at draft PR/human review. AgentStack CLI is absent; the authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes.

## Issue #26: nullable structural collection elements (2026-10-09)

- Scope: preserve null elements when structurally mapping distinct source/destination classes; apply the element Error/Throw policy before a non-null structural helper is invoked. Cover arrays, lists, enumerable/interface targets, dictionary values, nested collections, and existing member/update paths.
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 #27 enum diagnostic-name checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 8d41533957c7b5b27943327c8b6b3eef171b4232 after PR #40 merged. Issue #27 was the oldest remaining P2; 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: the completed 31-case fixture yielded 23 failures and eight controls on unchanged production, zero skips. An additional nullable tuple-field case independently failed before the fix. The Basic sample also failed CS8081. Earlier 23-case exploration included an adjacent null-preservation runtime case; its exact failing fixture is retained in task evidence and the scope boundary is described below.
- Implementation: pass stable source parameter/member/path/element names through enum planning and nullable recursion. Escape them as string literals in ArgumentOutOfRangeException metadata; preserve existing value captures, ActualValue, null guards, enum policy, and getter evaluation. The root snapshot changes only equivalent nameof(source) formatting.
- Verified core evidence: final Release solution build has zero warnings/errors. All 759 generator tests pass, including 32 new cases, plus five runtime and one integration test, zero failures/skips. All three source samples pass. C# 9 fixtures assert successful compilation without generated warnings; actual netstandard2.0/C# 9 package-generated source was inspected. Independent read-only review and CRLF-aware diff checks pass.
- Package coverage: the Basic sample, clean-package consumers, all 12 framework/language combinations, and trimming/Native AOT consumers contain the nullable Child.State reproduction. The available package suite first recorded 216 passes and six failures, zero skips: generated consumers filled the 4.9 GB temporary filesystem, while trimming reached the established task-host restriction. Only this task's generated consumers were preserved in the larger workspace; rerunning the six affected cases with a workspace-local temporary directory produced four passes and two infrastructure failures. Across the full run and focused recovery, all 220 available non-publish package cases pass. This is not a single all-green invocation.
- Remaining 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 unavailable; 49 PowerShell-dependent cases were not run locally. No warning, assertion, dependency, prerequisite, credential, or security setting was weakened. Ordinary exact-head Windows/Linux CI evidence belongs in the draft PR.
- Known separate defect: a nullable intermediate with a non-nullable enum leaf and nullable enum target still fails runtime null preservation. The committed case verifies only compilation and stable naming for this boundary; the original runtime-failing reproduction is retained separately, and usage/decision/PR text disclose the limitation. No issue among #28-32 tracks this variant, and no new issue was created.
- Boundary: issue #27 only, then draft PR/human review. No user-computer access, other bug fix, merge, release, tag push, package publication, or workflow dispatch.

## GitHub issue #26 nullable structural element checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 38e313ef836f9400dd0eeaea56d48ba35dca733d after PR #39 merged. Issue #26 was the oldest remaining P2, with no blocking dependencies, sub-issues, or competing open PRs. User-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
15 changes: 15 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,21 @@ public static partial class NullableStatusMapper

The same name matching and null preservation apply to members and collection elements. A nullable source targeting a non-null enum follows `Error` or `Throw`; unknown numeric values still throw `ArgumentOutOfRangeException` under by-name mapping. Patch mappings with `IgnoreNullSourceMembers` preserve the existing enum value when the source is null.

The Basic sample includes a configured enum path with a nullable intermediate:

```csharp
[LiteMapper(NullableMismatch = NullableMismatchPolicy.Throw)]
public static partial class EnumPathMapper
{
[MapProperty(Source = "Child.State", Target = nameof(EnumPathTarget.State))]
public static partial EnumPathTarget Map(EnumPathSource source);
}
```

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.

## Recursive mappings and cycle detection

From `samples/Mammoth.LiteMapper.Samples.Basic/Program.cs`:
Expand Down
46 changes: 46 additions & 0 deletions samples/Mammoth.LiteMapper.Samples.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ internal static class Program
{
private static void Main()
{
EnumPathExample.Run();
var staticTarget = StaticMapper.Map(new StaticSource
{
Name = "Ada",
Expand Down Expand Up @@ -187,3 +188,48 @@ public sealed class NodeTarget
public NodeTarget? Next { get; set; }
}
}

namespace Mammoth.LiteMapper.Samples.Basic
{
public static class EnumPathExample
{
public static void Run()
{
var source = new EnumPathSource { Child = new EnumPathChild { State = InputState.Ready } };
if (EnumPathMapper.Map(source).State != OutputState.Ready)
throw new System.InvalidOperationException("Enum source-path mapping failed.");
source.Child.State = (InputState)99;
try
{
EnumPathMapper.Map(source);
throw new System.InvalidOperationException("Unknown enum value was accepted.");
}
catch (System.ArgumentOutOfRangeException error) when (
error.ParamName == "Child.State" && error.ActualValue is InputState value && value == (InputState)99)
{
}
source.Child = null;
try
{
EnumPathMapper.Map(source);
throw new System.InvalidOperationException("Missing enum source path was accepted.");
}
catch (System.InvalidOperationException error) when (error.Message.Contains("Child.State"))
{
}
}
}

[LiteMapper(NullableMismatch = NullableMismatchPolicy.Throw)]
public static partial class EnumPathMapper
{
[MapProperty(Source = "Child.State", Target = nameof(EnumPathTarget.State))]
public static partial EnumPathTarget Map(EnumPathSource source);
}

public enum InputState { Ready = 1 }
public enum OutputState { Ready = 10 }
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; } }
}
18 changes: 9 additions & 9 deletions src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,7 @@ private static HashSet<string> ParseMemberNames(IMethodSymbol method, string att
return null;
}

var enumMapping = ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, sourceMember, sourcePath?.Expression ?? targetMember.Name, diagnostics, location, options);
var enumMapping = ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, sourceMember, sourcePath?.Expression ?? targetMember.Name, sourcePath?.Expression ?? sourceMember!.Name, diagnostics, location, options);
if (enumMapping != null || diagnostics.Count != languageDiagnosticCount)
{
return enumMapping;
Expand Down Expand Up @@ -1213,7 +1213,7 @@ private static HashSet<string> ParseMemberNames(IMethodSymbol method, string att
return new ConversionModel(languageConversion, null, potentiallyNull: false, mappingMethod.Name, nullCheckExpression: null);
}

return ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, null, mappingMethod.Name, diagnostics, location, options, isRoot: true);
return ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, null, mappingMethod.Name, mappingMethod.Parameters[0].Name, diagnostics, location, options, isRoot: true);
}

private static bool RequiresCollectionCopy(ITypeSymbol sourceType, ITypeSymbol targetType, Compilation compilation)
Expand Down Expand Up @@ -1300,7 +1300,7 @@ private static bool IsNumericType(ITypeSymbol type)
type.SpecialType == SpecialType.System_IntPtr || type.SpecialType == SpecialType.System_UIntPtr;
}

private static ConversionModel? ResolveEnumMapping(IMethodSymbol mappingMethod, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, string targetName, ICollection<Diagnostic> diagnostics, Location? location, EffectiveMappingOptions options, bool isRoot = false, bool isElement = false)
private static ConversionModel? ResolveEnumMapping(IMethodSymbol mappingMethod, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, string targetName, string sourceValueName, ICollection<Diagnostic> diagnostics, Location? location, EffectiveMappingOptions options, bool isRoot = false, bool isElement = false)
{
var nullableSource = sourceType is INamedTypeSymbol sourceValue && sourceValue.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T;
var nullableTarget = targetType is INamedTypeSymbol targetValue && targetValue.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T;
Expand All @@ -1325,7 +1325,7 @@ private static bool IsNumericType(ITypeSymbol type)
valueName += "_";
}

var underlyingMapping = ResolveEnumMapping(mappingMethod, underlyingSource, underlyingTarget, nullableSource ? valueName : expression, sourceMember, targetName, diagnostics, location, options);
var underlyingMapping = ResolveEnumMapping(mappingMethod, underlyingSource, underlyingTarget, nullableSource ? valueName : expression, sourceMember, targetName, sourceValueName, diagnostics, location, options);
if (underlyingMapping == null)
{
return null;
Expand Down Expand Up @@ -1471,7 +1471,7 @@ private static bool IsNumericType(ITypeSymbol type)
}

var captureValue = !(SyntaxFactory.ParseExpression(expression) is IdentifierNameSyntax);
arms = arms.Concat(new[] { (captureValue ? "var " + enumValueName : "_") + " => throw new global::System.ArgumentOutOfRangeException(nameof(" + expression + "), " + (captureValue ? enumValueName : expression) + ", \"Unmapped enum value.\")" });
arms = arms.Concat(new[] { (captureValue ? "var " + enumValueName : "_") + " => throw new global::System.ArgumentOutOfRangeException(" + Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(sourceValueName, quote: true) + ", " + (captureValue ? enumValueName : expression) + ", \"Unmapped enum value.\")" });
return new ConversionModel(expression + " switch\n {\n " + string.Join(",\n ", arms) + "\n }", sourceMember, potentiallyNull: false, targetName, nullCheckExpression: null);
}

Expand Down Expand Up @@ -1958,8 +1958,8 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod,

if (targetShape.IsDictionary)
{
var keyConversion = ResolveElementExpression(method, sourceShape.KeyType!, targetShape.KeyType!, itemLocal + ".Key", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance);
var valueConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal + ".Value", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance);
var keyConversion = ResolveElementExpression(method, sourceShape.KeyType!, targetShape.KeyType!, itemLocal + ".Key", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, sourceValueName: "Key");
var valueConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal + ".Value", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, sourceValueName: "Value");
if (keyConversion == null || valueConversion == null)
{
return null;
Expand Down Expand Up @@ -2076,7 +2076,7 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod,
return builder.ToString();
}

private static string? ResolveElementExpression(IMethodSymbol method, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, Compilation compilation, ICollection<Diagnostic> diagnostics, ImmutableArray<MappingModel>.Builder helpers, HelperNameRegistry helperNames, EffectiveMappingOptions options, Location? location, ICollection<IMethodSymbol> declaredCalls, ref bool requiresInstance)
private static string? ResolveElementExpression(IMethodSymbol method, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, Compilation compilation, ICollection<Diagnostic> diagnostics, ImmutableArray<MappingModel>.Builder helpers, HelperNameRegistry helperNames, EffectiveMappingOptions options, Location? location, ICollection<IMethodSymbol> declaredCalls, ref bool requiresInstance, string sourceValueName = "item")
{
if (ReportDuplicateVisibleDefaults(method, sourceType, targetType, compilation, diagnostics, "item", location))
{
Expand Down Expand Up @@ -2127,7 +2127,7 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod,
return null;
}

var enumMapping = ResolveEnumMapping(method, sourceType, targetType, expression, sourceMember, "item", diagnostics, location, options, isElement: true);
var enumMapping = ResolveEnumMapping(method, sourceType, targetType, expression, sourceMember, "item", sourceValueName, diagnostics, location, options, isElement: true);
if (enumMapping != null || diagnostics.Count != languageDiagnosticCount)
{
return enumMapping?.Expression;
Expand Down
Loading
Loading