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 #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)
- 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)
Expand Down
6 changes: 6 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Mammoth.LiteMapper Decision Log

## 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.
- Decision: inspect the constructed HashSet type in the consumer compilation, requiring an accessible constructor with by-value Int32 and, independently when preserving a comparer, the exact constructed IEqualityComparer element type. Use the parameterless or comparer-only fallback if that overload is unavailable. Unknown-count sources are not enumerated to size a destination.
- Contract: sections 3.5, 15.3, 15.5-15.9, 19.9, and 22.12/22.17 already require capability-based portable optimization. No public API, comparer-selection, or specification semantics change is introduced; list and dictionary construction remain outside scope.

## 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.
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 #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.
- Test-first: synthetic missing/inaccessible/inexact/asymmetric constructor fixtures, C# 9 runtime controls, same-driver capability changes, and actual clean-package netstandard2.0/net8.0/net9.0/net10.0 consumers.
- Implementation: select the constructor from the target element type and consumer Roslyn symbols, never from a target-framework name. Add a normally compiled Collections sample reused by package/matrix/trim/AOT consumers.
- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language matrix, available packaging/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI.
- Boundary: issue #30 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 #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.
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 #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.
- Red-first: final 36-case generator fixture on unchanged production yielded 15 failures and 21 controls, zero skips. The genuine package matrix independently produced three netstandard2.0 failures (C# 9/14/latest), each with four CS1503 capacity-constructor errors, and nine passing modern-framework controls. Packages for this baseline were built before the production fix.
- Implementation: independently probe exact accessible by-value HashSet(Int32) and HashSet(Int32, IEqualityComparer<T>) constructors on the constructed consumer symbol. Counted set mappings use the proven overload; missing capabilities fall back to parameterless/comparer-only construction. List/dictionary construction, comparer selection, public API, and specification semantics are unchanged.
- Core evidence: final Release solution build has zero warnings/errors. All 873 generator tests pass, including 36 new cases; five runtime and one integration test pass, zero failures/skips. The final CRLF fixture rerun passes 36/36. Capability fixtures cover absent, asymmetric, inaccessible, inexact, and by-ref signatures, internal accessibility, same-driver changes, C# 9 compilation, converted contents, duplicate collapse, comparer identity, independent collections, and one enumeration. Independent read-only review found no remaining blocker; consumer-skill and CRLF-aware diff checks pass.
- Package evidence: available packaging suite completed with 224 passes, two infrastructure failures, zero skips. All 12 framework/language combinations compile and execute, asserting parameterless/comparer-only construction on actual netstandard2.0 references and capacity/combined overloads on net8.0/net9.0/net10.0. Clean-package/source samples, usage validation, package/API checks, and benchmark smoke pass. The shared PortableSetExample covers array/list sources, HashSet/ISet and available IReadOnlySet destinations, converted values, compatible comparers, and independent identity. Clean-package copying now includes all top-level sample C# files; normal, trimmed, and Native AOT consumer fixtures reuse the same example.
- 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 assertion, warning, dependency, prerequisite, credential, or security setting was weakened. The fresh cloud workspace restored the previously authorized official Microsoft SDK 10.0.401 and runtimes 8.0.31/9.0.20; runtime 10.0.12 is included. Caches and temporary files remain task-local with one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR.
- Boundary: issue #30 only, then human review. Dictionary-comparer issue #31 and the previously documented nullable-intermediate enum defect remain outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch.

## 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.
Expand Down
4 changes: 3 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,9 @@ Supported collection mapping includes:

Nullable structural elements preserve null without calling an object-mapping helper. In the Collections sample, `MapNullableLines` maps a three-element source containing null, a line with quantity 7, and null to three elements with the same null positions and mapped quantity. This also applies to dictionary values and nested collections. Mapping a nullable element to a non-null target reports `LITEMAPPER2003` under `NullableMismatchPolicy.Error`; `Throw` checks the element before mapping and raises `InvalidOperationException` with its source element path. `NullCollectionStrategy.Empty` applies to the collection itself and does not replace or remove null object elements.

LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information is available. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays.
LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information and the exact accessible constructor are available. Set mappings use parameterless or comparer-only construction on targets such as `netstandard2.0` where capacity overloads are absent. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays.

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.

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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using Mammoth.LiteMapper;

namespace Mammoth.LiteMapper.Samples.Collections
{
public static class PortableSetExample
{
public static void Run()
{
var array = new[] { 1, 2, 1 };
var list = new List<int>(array);
Check(PortableSetMapper.ArrayToHashSet(array), array);
Check(PortableSetMapper.ArrayToSet(array), array);
Check(PortableSetMapper.ListToHashSet(list), list);
Check(PortableSetMapper.ListToSet(list), list);
#if !NETSTANDARD2_0
Check(PortableSetMapper.ArrayToReadOnlySet(array), array);
Check(PortableSetMapper.ListToReadOnlySet(list), list);
#endif
var source = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "A" };
CheckComparer(PortableSetMapper.CopyHashSet(source), source);
CheckComparer(PortableSetMapper.CopySet(source), source);
#if !NETSTANDARD2_0
CheckComparer(PortableSetMapper.CopyReadOnlySet(source), source);
#endif
if (PortableSetMapper.ArrayToHashSet(Array.Empty<int>()).Count != 0)
{
throw new InvalidOperationException("An empty source must produce an empty set.");
}
}

private static void Check(object mapped, object source)
{
if (!(mapped is HashSet<long> set) || !set.SetEquals(new long[] { 1, 2 }) ||
ReferenceEquals(source, mapped) || !ReferenceEquals(set.Comparer, EqualityComparer<long>.Default))
{
throw new InvalidOperationException("Converted sets must preserve contents and use independent default-comparer collections.");
}
}

private static void CheckComparer(object mapped, HashSet<string> source)
{
if (!(mapped is HashSet<string> set) || !set.Contains("a") ||
ReferenceEquals(source, mapped) || !ReferenceEquals(set.Comparer, source.Comparer))
{
throw new InvalidOperationException("Compatible set copies must preserve the comparer without aliasing.");
}

set.Add("B");
if (source.Count != 1 || set.Count != 2)
{
throw new InvalidOperationException("Changing a set copy must not change its source.");
}
}
}

[LiteMapper]
public static partial class PortableSetMapper
{
public static partial HashSet<long> ArrayToHashSet(int[] source);
public static partial ISet<long> ArrayToSet(int[] source);
public static partial HashSet<long> ListToHashSet(List<int> source);
public static partial ISet<long> ListToSet(List<int> source);
public static partial HashSet<string> CopyHashSet(HashSet<string> source);
public static partial ISet<string> CopySet(HashSet<string> source);
#if !NETSTANDARD2_0
public static partial IReadOnlySet<long> ArrayToReadOnlySet(int[] source);
public static partial IReadOnlySet<long> ListToReadOnlySet(List<int> source);
public static partial IReadOnlySet<string> CopyReadOnlySet(HashSet<string> source);
#endif
}
}
2 changes: 2 additions & 0 deletions samples/Mammoth.LiteMapper.Samples.Collections/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ internal static class Program
{
private static void Main()
{
PortableSetExample.Run();

var nullableLines = NullableLineMapper.MapNullableLines(new LineSource?[]
{
null, new LineSource { Sku = "A", Quantity = 7 }, null,
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 @@ -102,6 +102,8 @@ Name matching uses ordinal comparisons. `Exact` accepts only an exact name; `Exa

## Collections, enums, and cycles

Set capacity is used only when the consumer compilation exposes the exact accessible constructor. Arrays and lists can map to `HashSet<T>` or `ISet<T>` on `netstandard2.0` through a parameterless/comparer-only fallback; `IReadOnlySet<T>` requires that interface to exist. Capacity selection does not change duplicate collapsing, compatible-comparer preservation, or independent collection identity.

Enum numeric policy applies to declared values and unmatched-name ByValue fallback: Unchecked300 to a byte-backed enum yields44; Checked retains overflow validation. Missing composite flag names may map through their atoms (target Read4 OR Write8 gives12), including signed high-bit source flags; unknown source bits throw. A matching composite name must equal the mapped atomic OR: target Both12 is valid, Both16 reports LITEMAPPER7002 and omits the mapping. Every ordinary source alias must resolve consistently: Known/Alias1 may map to Known/Alias9.

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.
Expand Down
19 changes: 17 additions & 2 deletions src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2021,15 +2021,17 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod,
builder.Append(ConcreteCollectionType(targetShape, targetType));
var countExpression = SourceCountExpression(sourceType, sourceShape, sourceExpression);
var preserveComparer = CanPreserveComparer(sourceShape, targetShape, sourceType, targetType);
var useCapacity = countExpression != null && (targetShape.Kind != CollectionKind.Set ||
HasHashSetCapacityConstructor(compilation, targetShape.ElementType, method.ContainingType, preserveComparer));
builder.Append('(');
if (countExpression != null && !(preserveComparer && targetShape.Kind == CollectionKind.Set))
if (useCapacity)
{
builder.Append(countExpression);
}

if (preserveComparer)
{
if (countExpression != null && targetShape.Kind == CollectionKind.Dictionary)
if (useCapacity)
{
builder.Append(", ");
}
Expand Down Expand Up @@ -2400,6 +2402,19 @@ private static string RenderEmptyCollection(CollectionShape shape, ITypeSymbol t
return "new " + ConcreteCollectionType(shape, targetType) + "()";
}

private static bool HasHashSetCapacityConstructor(Compilation compilation, ITypeSymbol elementType, INamedTypeSymbol mapperType, bool preserveComparer)
{
var setType = compilation.GetTypeByMetadataName("System.Collections.Generic.HashSet`1")?.Construct(elementType);
var comparerType = compilation.GetTypeByMetadataName("System.Collections.Generic.IEqualityComparer`1")?.Construct(elementType);
return setType != null && setType.InstanceConstructors.Any(constructor =>
compilation.IsSymbolAccessibleWithin(constructor, mapperType) &&
constructor.Parameters.Length == (preserveComparer ? 2 : 1) &&
constructor.Parameters.All(static parameter => parameter.RefKind == RefKind.None) &&
constructor.Parameters[0].Type.SpecialType == SpecialType.System_Int32 &&
(!preserveComparer || comparerType != null &&
SymbolEqualityComparer.Default.Equals(constructor.Parameters[1].Type, comparerType)));
}

private static bool HasArrayEmpty(Compilation compilation)
{
var arrayType = compilation.GetSpecialType(SpecialType.System_Array);
Expand Down
Loading
Loading