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 #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)
- Issue #28: IgnoreCase rejects multiple case-insensitive source matches even when one is exact; nested structural ambiguity retains LITEMAPPER1004. Exact and ExactThenIgnoreCase keep their existing exact-match priority. [#28](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/28)
- 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)
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 #31: classify the selected dictionary key conversion (2026-10-09)

- Context: the shared comparer predicate compared dictionary value types, so widening or structurally mapping values discarded an otherwise compatible key comparer. It also preserved comparers for same-type keys without checking whether a custom conversion was selected.
- Decision: retain a language-identity flag from the successful element-resolution stage. Dictionary construction requires that flag and matching key types, independently of value types. Earlier converter/mapping stages never claim language identity. Preserve existing concrete-source eligibility, set comparer selection, and Add insertion.
- Contract: sections 11.1, 15.7, 15.9-15.10, 19.4, and 22.12 already define these semantics. No public API or specification change is introduced; reflection and runtime comparer discovery remain absent.
- Evidence: the initial 31-case C# 9 fixture on unchanged production produced 19 failures and 12 controls, zero skips. Cases distinguish value-only conversion from same-type custom key conversion, verify all dictionary destinations, and retain collision, copying, null, helper/update, and deterministic same-driver behavior.

## Issue #30: independently select exact HashSet capacity overloads (2026-10-09)

- Context: counted set mappings always emitted a capacity argument without probing the reference API; netstandard2.0 lacks that constructor. Preserved-comparer mappings always omitted capacity, even when the combined overload exists.
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 #31: dictionary key comparer preservation (2026-10-09)

- Scope: preserve a concrete Dictionary source's compatible key comparer when key resolution selects language identity, independently of value conversion. Converted keys use the destination default comparer; retain Add collision semantics and existing set behavior.
- Test-first: reproduce value widening/structural conversion across concrete and interface destinations, and same-type custom key conversion; retain independent copies, empty/null controls, member/update paths, converter precedence, deterministic C# 9 output, and real package consumers.
- Implementation: carry the selected key conversion's identity classification from the existing resolver; select the dictionary comparer by key type rather than value type. No public API or specification semantics change.
- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language matrix, available package/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI.
- Boundary: issue #31 only, then draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; approved connector fallback adds/readbacks readiness labels without issue comments or claim writes.

## Issue #30: symbol-proven HashSet capacity constructors (2026-10-09)

- Scope: probe the exact accessible HashSet capacity and capacity-plus-comparer constructors independently; use portable parameterless/comparer-only construction when unavailable. Preserve set contents, comparer selection, independent identity, and one enumeration. Do not change list or dictionary construction.
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 #31 dictionary comparer checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop c973aac8e457cc40a4069889aeab49d8278ccca3 after PR #44 merged. Issue #31 was the older remaining P3, with no blocked-by dependencies, sub-issues, or competing PR. Explicitly 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: 31 new C# 9 generator cases on unchanged production produced 19 failures and 12 controls, zero skips. Widened and structural values discarded key comparer identity; same-type custom key conversions incorrectly retained it. All fixture inputs compile cleanly. The normally compiled Collections sample independently failed its comparer-preservation assertion after building with zero warnings/errors.
- Implementation: carry an identity flag only from the successful language-conversion stage of key resolution. Dictionary preservation checks compatible key types rather than value types. Earlier custom converter/mapping stages remain non-identity; concrete-source eligibility, Add insertion, set comparer behavior, public API, and specification semantics remain unchanged.
- Core evidence: Release solution build has zero warnings/errors. All 904 generator tests, five runtime tests, and one integration test pass with zero failures/skips. The focused dictionary/collection/set group passes 96/96. All three source samples pass. Actual generated sample source has comparer-bearing construction for identity keys and default construction for transformed keys, with one foreach/Add loop. Independent read-only review, consumer-skill validation, and CRLF-aware diff checks pass.
- Package evidence: available packaging suite records 224 passes, two infrastructure failures, zero skips. All 12 framework/language combinations compile and execute the shared DictionaryComparerExample, including genuine netstandard2.0 references. The example covers widening/structural values and custom-key default-comparer/collision controls across Dictionary, IDictionary, and IReadOnlyDictionary destinations. Clean-package samples, usage validation, package/API checks, and benchmark smoke pass. Normal, trimmed, and Native AOT package consumers reuse the same source.
- Local limits: trimming and Native AOT fail before publish validation 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. Existing SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12 use workspace-local caches and temporary files with one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR.
- Boundary: issue #31 only, then human review. The previously documented nullable-intermediate enum defect and issue #32 remain outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch.

## GitHub issue #30 HashSet constructor capability checkpoint (2026-10-09)

- Base: isolated cloud worktree from develop 63885e1af353b45f85c28fdf2306c06409cd92bb after PR #43 merged. Issue #30 was the only 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,8 @@ LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocate

The [portable set example](../samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs) maps arrays and lists to `HashSet<long>` and `ISet<long>`, collapsing duplicate converted values. It also verifies detached copies with preserved case-insensitive comparers. `IReadOnlySet<T>` is included only when that interface exists.

The [dictionary comparer example](../samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs) preserves a concrete dictionary source's case-insensitive key comparer when widening `int` values to `long` or structurally mapping value objects. This applies to `Dictionary`, `IDictionary`, and `IReadOnlyDictionary` destinations. Comparer selection depends on the selected key conversion, independently of value conversion: custom key converters use the destination default comparer even when their input and output key types match. Converted-key collisions throw through normal `Add` semantics. Interface-declared dictionary sources use the default comparer because their declared contracts do not expose a comparer; LiteMapper does not discover it at runtime.

For array results from sources without a cheap, reliable count, mapping uses an O(n) temporary growing buffer and then creates the final array. It does not count by enumerating first, and each element is converted once. Counted sources allocate their final array directly. The same rule applies to interfaces whose concrete result is an array.

Public collection mappings are allowed when the declared mapping method itself maps one supported collection shape to another supported collection shape:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using Mammoth.LiteMapper;

namespace Mammoth.LiteMapper.Samples.Collections
{
public static class DictionaryComparerExample
{
public static void Run()
{
var numbers = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["A"] = 7 };
CheckWidened(DictionaryComparerMapper.WidenToDictionary(numbers), numbers);
CheckWidened(DictionaryComparerMapper.WidenToInterface(numbers), numbers);
CheckWidened(DictionaryComparerMapper.WidenToReadOnlyDictionary(numbers), numbers);

var values = new Dictionary<string, DictionaryValueSource>(StringComparer.OrdinalIgnoreCase)
{
["A"] = new DictionaryValueSource { Value = 7 },
};
CheckStructural(DictionaryComparerMapper.MapToDictionary(values), values);
CheckStructural(DictionaryComparerMapper.MapToInterface(values), values);
CheckStructural(DictionaryComparerMapper.MapToReadOnlyDictionary(values), values);

CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToDictionary);
CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToInterface);
CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToReadOnlyDictionary);
}

private static void CheckWidened(object mapped, Dictionary<string, int> source)
{
if (!(mapped is Dictionary<string, long> dictionary) || dictionary.Count != 1 ||
!dictionary.TryGetValue("a", out var value) || value != 7 ||
ReferenceEquals(source, mapped) || !ReferenceEquals(dictionary.Comparer, source.Comparer))
{
throw new InvalidOperationException("Widening dictionary values must preserve the key comparer without aliasing.");
}

dictionary["A"] = 9;
dictionary.Add("B", 2);
if (source.Count != 1 || source["A"] != 7 || dictionary.Count != 2)
{
throw new InvalidOperationException("Changing a widened dictionary must not change its source.");
}
}

private static void CheckStructural(object mapped, Dictionary<string, DictionaryValueSource> source)
{
if (!(mapped is Dictionary<string, DictionaryValueTarget> dictionary) || dictionary.Count != 1 ||
!dictionary.TryGetValue("a", out var value) || value.Value != 7 ||
ReferenceEquals(source, mapped) || ReferenceEquals(source["A"], value) ||
!ReferenceEquals(dictionary.Comparer, source.Comparer))
{
throw new InvalidOperationException("Structural dictionary values must preserve the key comparer without aliasing.");
}

value.Value = 9;
dictionary.Add("B", new DictionaryValueTarget { Value = 2 });
if (source.Count != 1 || source["A"].Value != 7 || dictionary.Count != 2)
{
throw new InvalidOperationException("Changing a mapped dictionary or value must not change its source.");
}
}

private static void CheckConvertedKeys(Func<Dictionary<string, int>, object> map)
{
var source = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["A"] = 7 };
var mapped = map(source);
if (!(mapped is Dictionary<string, int> dictionary) || dictionary.Count != 1 ||
!dictionary.TryGetValue("a", out var value) || value != 7 || dictionary.ContainsKey("A") ||
ReferenceEquals(source, mapped) || !ReferenceEquals(dictionary.Comparer, EqualityComparer<string>.Default))
{
throw new InvalidOperationException("Same-type converted keys must use the destination default comparer.");
}

dictionary.Add("A", 9);
if (source.Count != 1 || source["A"] != 7 || dictionary.Count != 2)
{
throw new InvalidOperationException("Converted keys must allow distinct default-comparer keys without changing the source.");
}

var colliding = new Dictionary<string, int>(StringComparer.Ordinal) { ["A"] = 1, ["a"] = 2 };
try
{
map(colliding);
}
catch (ArgumentException)
{
return;
}

throw new InvalidOperationException("Converted dictionary key collisions must throw using Add semantics.");
}
}

[LiteMapper]
public static partial class DictionaryComparerMapper
{
public static partial Dictionary<string, long> WidenToDictionary(Dictionary<string, int> source);
public static partial IDictionary<string, long> WidenToInterface(Dictionary<string, int> source);
public static partial IReadOnlyDictionary<string, long> WidenToReadOnlyDictionary(Dictionary<string, int> source);
public static partial Dictionary<string, DictionaryValueTarget> MapToDictionary(Dictionary<string, DictionaryValueSource> source);
public static partial IDictionary<string, DictionaryValueTarget> MapToInterface(Dictionary<string, DictionaryValueSource> source);
public static partial IReadOnlyDictionary<string, DictionaryValueTarget> MapToReadOnlyDictionary(Dictionary<string, DictionaryValueSource> source);
}

[LiteMapper]
public static partial class ConvertedDictionaryKeyMapper
{
public static partial Dictionary<string, int> ToDictionary(Dictionary<string, int> source);
public static partial IDictionary<string, int> ToInterface(Dictionary<string, int> source);
public static partial IReadOnlyDictionary<string, int> ToReadOnlyDictionary(Dictionary<string, int> source);

[MappingConverter]
private static string NormalizeKey(string key) => key.ToLowerInvariant();
}

public sealed class DictionaryValueSource
{
public int Value { get; set; }
}

public sealed class DictionaryValueTarget
{
public long Value { get; set; }
}
}
1 change: 1 addition & 0 deletions samples/Mammoth.LiteMapper.Samples.Collections/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()
{
PortableSetExample.Run();
DictionaryComparerExample.Run();

var nullableLines = NullableLineMapper.MapNullableLines(new LineSource?[]
{
Expand Down
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 @@ -108,7 +108,7 @@ Enum numeric policy applies to declared values and unmatched-name ByValue fallba

Declare public top-level collection mappings explicitly, for example `public static partial List<CustomerDto> MapCustomers(Customer[] source);` inside a static mapper. Supported shapes include one-dimensional/jagged arrays, common generic sequence/list interfaces, `List<T>`, sets, and dictionaries; `IReadOnlySet<T>` requires the symbol in the compilation.

Mutable collections are copied; elements are not necessarily deep-cloned. Arbitrary enumerable sources are traversed once. Null elements are not silently removed. Dictionary keys and values map separately; duplicate converted keys throw through normal `Add` semantics. Compatible identity key/element mappings can preserve comparers. Do not generate custom/immutable collection construction, queues, stacks, rectangular arrays, or async enumerables.
Mutable collections are copied; elements are not necessarily deep-cloned. Arbitrary enumerable sources are traversed once. Null elements are not silently removed. Dictionary keys and values map separately; duplicate converted keys throw through normal `Add` semantics. Compatible identity element mappings can preserve set comparers. Concrete Dictionary sources preserve compatible key comparers independently of value conversion, including widening or structural values. A selected custom key converter uses the destination default comparer even for identical key types; interface-declared sources do not expose a comparer for preservation. Do not generate custom/immutable collection construction, queues, stacks, rectangular arrays, or async enumerables.

Interface destinations use `T[]` for `IEnumerable<T>`, `IReadOnlyCollection<T>`, and `IReadOnlyList<T>`; `List<T>` for `ICollection<T>` and `IList<T>`; `HashSet<T>` for set interfaces; and `Dictionary<TKey, TValue>` for dictionary interfaces. The same result types apply to nested members and `NullCollections.Empty`. In 3.0.0, regression coverage verifies array results, mutable-list results, independent copies, one-pass enumeration, and nested/null-to-empty mappings.

Expand Down
Loading
Loading